triflux 9.8.2 → 9.8.3

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.
@@ -4,7 +4,7 @@
4
4
  // v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
5
5
  // 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
6
6
  import { join } from "node:path";
7
- import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
7
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, statSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { execSync, spawn } from "node:child_process";
10
10
  import { createRequire } from "node:module";
@@ -22,12 +22,15 @@ import {
22
22
  import { HANDOFF_INSTRUCTION_SHORT, processHandoff } from "./handoff.mjs";
23
23
  import { getBackend } from "./backend.mjs";
24
24
  import { resolveDashboardLayout } from "./dashboard-layout.mjs";
25
- import { normalizeDashboardAnchor } from "./dashboard-anchor.mjs";
26
25
  import { createLogDashboard } from "./tui.mjs";
27
- import { createLiteDashboard } from "./tui-lite.mjs";
28
26
 
29
27
  const RESULT_DIR = join(tmpdir(), "tfx-headless");
30
28
 
29
+ // remote-spawn.mjs의 escapePwshSingleQuoted와 동일 — 순환 의존 방지를 위해 인라인
30
+ function escapePwshSingleQuoted(value) {
31
+ return String(value).replace(/'/g, "''");
32
+ }
33
+
31
34
  /** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
32
35
  const CLI_BRAND = {
33
36
  codex: { emoji: "\u{26AA}", label: "Codex", ansi: "\x1b[97m" }, // ⚪ bright white (codexWhite)
@@ -51,11 +54,6 @@ export function resolveCliType(agentOrCli) {
51
54
  return AGENT_TO_CLI[agentOrCli] || agentOrCli;
52
55
  }
53
56
 
54
- // remote-spawn.mjs의 escapePwshSingleQuoted와 동일 — 순환 의존 방지를 위해 인라인
55
- function escapePwshSingleQuoted(value) {
56
- return String(value).replace(/'/g, "''");
57
- }
58
-
59
57
  /** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
60
58
  const MCP_PROFILE_HINTS = {
61
59
  implement: "You have full filesystem read/write access. Implement changes directly.",
@@ -73,7 +71,6 @@ const MCP_PROFILE_HINTS = {
73
71
  * @param {boolean} [opts.handoff=true]
74
72
  * @param {string} [opts.mcp] — MCP 프로필 ("implement"|"analyze"|"review"|"docs")
75
73
  * @param {string} [opts.contextFile] — 컨텍스트 파일 경로 (최대 32KB, UTF-8 안전 절단)
76
- * @param {string} [opts.cwd] — 워커 실행 작업 디렉터리
77
74
  * @returns {string} PowerShell 명령
78
75
  */
79
76
  export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
@@ -128,6 +125,215 @@ function readResult(resultFile, paneId) {
128
125
  return capturePsmuxPane(paneId, 30);
129
126
  }
130
127
 
128
+ // ─── Stall Detection ───
129
+
130
+ /** Stall detection 기본값 (immutable) */
131
+ export const STALL_DEFAULTS = Object.freeze({
132
+ pollInterval: 5_000,
133
+ stallTimeout: 120_000,
134
+ completionTimeout: 900_000,
135
+ maxRestarts: 2,
136
+ });
137
+
138
+ /** CLI pane stall 감지 에러 (STALL_EXHAUSTED | COMPLETION_TIMEOUT) */
139
+ export class StallError extends Error {
140
+ constructor(message, { code = "STALL_DETECTED", category = "transient", recovery = "" } = {}) {
141
+ super(message);
142
+ this.name = "StallError";
143
+ this.code = code;
144
+ this.category = category;
145
+ this.recovery = recovery;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Stall 모니터 팩토리 — output + resultFile mtime 하이브리드 감지
151
+ * @param {string} paneId
152
+ * @param {string} resultFile
153
+ * @param {{ stallTimeout: number }} config
154
+ * @param {{ capturePsmuxPane?: Function, statSync?: Function }} [deps]
155
+ * @returns {{ poll: () => { snapshot: string, mtimeChanged: boolean, stalled: boolean, elapsed: number } }}
156
+ */
157
+ export function createStallMonitor(paneId, resultFile, config, deps = {}) {
158
+ const capture = deps.capturePsmuxPane || capturePsmuxPane;
159
+ const stat = deps.statSync || statSync;
160
+ let lastSnapshot = "";
161
+ let lastMtime = 0;
162
+ let lastChangeAt = Date.now();
163
+
164
+ try { lastMtime = stat(resultFile).mtimeMs; } catch { /* not created yet */ }
165
+
166
+ return Object.freeze({
167
+ poll() {
168
+ const snapshot = capture(paneId, 50);
169
+ let currentMtime = 0;
170
+ try { currentMtime = stat(resultFile).mtimeMs; } catch { /* ignore */ }
171
+
172
+ const outputChanged = snapshot !== lastSnapshot;
173
+ const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
174
+
175
+ if (outputChanged || mtimeChanged) {
176
+ lastChangeAt = Date.now();
177
+ lastSnapshot = snapshot;
178
+ if (mtimeChanged) lastMtime = currentMtime;
179
+ }
180
+
181
+ const elapsed = Date.now() - lastChangeAt;
182
+ return Object.freeze({
183
+ snapshot,
184
+ mtimeChanged,
185
+ stalled: elapsed >= config.stallTimeout,
186
+ elapsed,
187
+ });
188
+ },
189
+ });
190
+ }
191
+
192
+ /**
193
+ * 하이브리드 stall 감지 대기 — output 변화 + resultFile mtime 모니터링.
194
+ * 2분 무변화 시 pane kill → re-dispatch (최대 2회 재시작).
195
+ *
196
+ * @param {string} sessionName
197
+ * @param {string} paneId — 현재 pane 타겟 (예: "tfx:0.1")
198
+ * @param {string} resultFile — 결과 저장 파일 경로
199
+ * @param {object} [opts]
200
+ * @param {number} [opts.pollInterval=5000] — 폴링 간격 ms
201
+ * @param {number} [opts.stallTimeout=120000] — 무변화 stall 판정 ms
202
+ * @param {number} [opts.completionTimeout=900000] — 전체 타임아웃 ms
203
+ * @param {number} [opts.maxRestarts=2] — 최대 재시작 횟수
204
+ * @param {string} [opts.command] — re-dispatch용 원본 명령
205
+ * @param {string} [opts.token] — completion token
206
+ * @param {(snapshot: string) => void} [opts.onPoll] — 폴링 콜백
207
+ * @returns {Promise<{ matched: boolean, exitCode: number|null, restarts: number, stallDetected: boolean }>}
208
+ */
209
+ export async function waitForCompletionWithStallDetect(sessionName, paneId, resultFile, opts = {}) {
210
+ const {
211
+ pollInterval = 5000,
212
+ stallTimeout = 120000,
213
+ completionTimeout = 900000,
214
+ maxRestarts = 2,
215
+ command,
216
+ token,
217
+ onPoll,
218
+ _deps,
219
+ } = opts;
220
+
221
+ // 의존성 (테스트 시 _deps로 주입 가능)
222
+ const deps = _deps || {};
223
+ const _capture = deps.capturePsmuxPane || capturePsmuxPane;
224
+ const _exists = deps.existsSync || existsSync;
225
+ const _stat = deps.statSync || statSync;
226
+ const _readFile = deps.readFileSync || readFileSync;
227
+ const _exec = deps.psmuxExec || psmuxExec;
228
+ const _dispatch = deps.dispatchCommand || dispatchCommand;
229
+ const _startCapture = deps.startCapture || startCapture;
230
+
231
+ const _PREFIX = "__TRIFLUX_DONE__:";
232
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
233
+ const completionRe = token
234
+ ? new RegExp(`${esc(_PREFIX)}${esc(token)}:(\\d+)`, "m")
235
+ : new RegExp(`${esc(_PREFIX)}\\S+:(\\d+)`, "m");
236
+
237
+ let restarts = 0;
238
+ let currentPaneId = paneId;
239
+ let stallDetected = false;
240
+
241
+ while (true) {
242
+ let lastOutput = "";
243
+ let lastMtime = 0;
244
+ let lastChangeAt = Date.now();
245
+ const startedAt = Date.now();
246
+
247
+ // 초기 resultFile mtime
248
+ try {
249
+ if (_exists(resultFile)) lastMtime = _stat(resultFile).mtimeMs;
250
+ } catch { /* 무시 */ }
251
+
252
+ while (true) {
253
+ await new Promise((r) => setTimeout(r, pollInterval));
254
+ const now = Date.now();
255
+
256
+ // 전체 타임아웃
257
+ if (now - startedAt > completionTimeout) {
258
+ return { matched: false, exitCode: null, restarts, stallDetected, timedOut: true };
259
+ }
260
+
261
+ // 1) capture-pane 출력 확인
262
+ const currentOutput = _capture(currentPaneId, 50);
263
+ if (onPoll) { try { onPoll(currentOutput); } catch { /* 삼킴 */ } }
264
+
265
+ // 2) completion 토큰 감지
266
+ const completionMatch = completionRe.exec(currentOutput);
267
+ if (completionMatch) {
268
+ return {
269
+ matched: true,
270
+ exitCode: Number.parseInt(completionMatch[1], 10),
271
+ restarts,
272
+ stallDetected,
273
+ timedOut: false,
274
+ };
275
+ }
276
+
277
+ // 3) resultFile 존재 + mtime 변화 확인
278
+ let currentMtime = 0;
279
+ try {
280
+ if (_exists(resultFile)) currentMtime = _stat(resultFile).mtimeMs;
281
+ } catch { /* 무시 */ }
282
+
283
+ // 4) 변화 감지 → stallTimer 리셋
284
+ const outputChanged = currentOutput !== lastOutput;
285
+ const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
286
+
287
+ if (outputChanged || mtimeChanged) {
288
+ lastChangeAt = now;
289
+ lastOutput = currentOutput;
290
+ if (mtimeChanged) lastMtime = currentMtime;
291
+ }
292
+
293
+ // resultFile이 갱신되고 내용이 있으면 완료로 간주
294
+ if (mtimeChanged && currentMtime > 0 && _exists(resultFile)) {
295
+ try {
296
+ const content = _readFile(resultFile, "utf8").trim();
297
+ if (content.length > 0) {
298
+ return { matched: true, exitCode: 0, restarts, stallDetected, timedOut: false };
299
+ }
300
+ } catch { /* 무시 */ }
301
+ }
302
+
303
+ // 5) stall 판정
304
+ if (now - lastChangeAt >= stallTimeout) {
305
+ stallDetected = true;
306
+
307
+ if (restarts >= maxRestarts) {
308
+ const err = new Error("CLI가 반복적으로 멈춤. 수동 확인 필요.");
309
+ err.code = "STALL_EXHAUSTED";
310
+ err.category = "transient";
311
+ err.recovery = "CLI가 반복적으로 멈춤. 수동 확인 필요.";
312
+ err.restarts = restarts;
313
+ throw err;
314
+ }
315
+
316
+ // kill pane → re-dispatch
317
+ try { _exec(["kill-pane", "-t", currentPaneId]); } catch { /* 이미 종료 */ }
318
+
319
+ if (command) {
320
+ // 새 pane split + 동일 command re-dispatch
321
+ const newPaneId = _exec([
322
+ "split-window", "-t", sessionName, "-P", "-F",
323
+ "#{session_name}:#{window_index}.#{pane_index}",
324
+ ]);
325
+ _startCapture(sessionName, newPaneId);
326
+ _dispatch(sessionName, newPaneId, command);
327
+ currentPaneId = newPaneId;
328
+ }
329
+
330
+ restarts++;
331
+ break; // inner loop 재시작 (stallTimer 리셋)
332
+ }
333
+ }
334
+ }
335
+ }
336
+
131
337
  /** progressive 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가하며 dispatch */
132
338
  async function dispatchProgressive(sessionName, assignments, opts = {}) {
133
339
  const {
@@ -175,7 +381,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
175
381
 
176
382
  // 캡처 시작 + 컬러 배너 + 명령 dispatch
177
383
  const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
178
- const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model, cwd: assignment.cwd });
384
+ const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model });
179
385
  startCapture(sessionName, newPaneId);
180
386
  // pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
181
387
  if (i > 0) await new Promise(r => setTimeout(r, 300));
@@ -183,7 +389,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
183
389
 
184
390
  if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
185
391
 
186
- dispatches.push({ ...dispatch, paneId: newPaneId, paneName, resultFile, cli: assignment.cli, role: assignment.role });
392
+ dispatches.push({ ...dispatch, paneId: newPaneId, paneName, resultFile, cli: assignment.cli, role: assignment.role, command: cmd });
187
393
  }
188
394
 
189
395
  // 모든 split 완료 후 레이아웃 한 번만 정렬 (깜빡임 방지)
@@ -219,7 +425,7 @@ function dispatchBatch(sessionName, assignments, opts = {}) {
219
425
  return assignments.map((assignment, i) => {
220
426
  const paneName = `worker-${i + 1}`;
221
427
  const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
222
- const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model, cwd: assignment.cwd });
428
+ const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model });
223
429
  const scriptDir = join(RESULT_DIR, sessionName);
224
430
  const dispatch = dispatchCommand(sessionName, paneName, cmd, { scriptDir, scriptName: paneName });
225
431
 
@@ -229,7 +435,7 @@ function dispatchBatch(sessionName, assignments, opts = {}) {
229
435
 
230
436
  if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
231
437
 
232
- return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role };
438
+ return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role, command: cmd };
233
439
  });
234
440
  }
235
441
 
@@ -242,13 +448,11 @@ function dispatchBatch(sessionName, assignments, opts = {}) {
242
448
  * @param {number} progressIntervalSec
243
449
  * @returns {Promise<Array<{d, completion, output}>>}
244
450
  */
245
- async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec) {
246
- const ac = new AbortController();
247
-
451
+ async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec, stallOpts) {
248
452
  // 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
249
453
  return Promise.all(dispatches.map(async (d) => {
250
454
  // onPoll → onProgress 변환 (throttle by progressIntervalSec)
251
- const pollOpts = { signal: ac.signal };
455
+ const pollOpts = {};
252
456
  if (safeProgress && progressIntervalSec > 0) {
253
457
  let lastProgressAt = 0;
254
458
  const intervalMs = progressIntervalSec * 1000;
@@ -266,13 +470,59 @@ async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progr
266
470
  };
267
471
  }
268
472
 
269
- // dispatch 시 확정된 logPath를 전달 — 셸이 pane 타이틀 변경해도 캡처 로그 매칭 유지
270
- if (d.logPath) pollOpts.logPath = d.logPath;
271
- const completion = await waitForCompletion(sessionName, d.paneId || d.paneName, d.token, timeoutSec, pollOpts);
272
-
273
- // 세션 사망 감지 나머지 워커 폴링 즉시 중단
274
- if (completion.sessionDead && !ac.signal.aborted) {
275
- ac.abort();
473
+ let completion;
474
+ if (stallOpts && stallOpts.enabled) {
475
+ // 하이브리드 stall detection 모드
476
+ try {
477
+ const stallPollCb = safeProgress && progressIntervalSec > 0
478
+ ? (snapshot) => {
479
+ try {
480
+ safeProgress({
481
+ type: "progress",
482
+ paneName: d.paneName,
483
+ cli: d.cli,
484
+ snapshot: snapshot.split("\n").slice(-15).join("\n"),
485
+ });
486
+ } catch { /* 삼킴 */ }
487
+ }
488
+ : undefined;
489
+
490
+ const stallResult = await waitForCompletionWithStallDetect(
491
+ sessionName,
492
+ d.paneId || d.paneName,
493
+ d.resultFile,
494
+ {
495
+ pollInterval: stallOpts.pollInterval,
496
+ stallTimeout: stallOpts.stallTimeout,
497
+ completionTimeout: stallOpts.completionTimeout ?? timeoutSec * 1000,
498
+ maxRestarts: stallOpts.maxRestarts,
499
+ command: d.command,
500
+ token: d.token,
501
+ onPoll: stallPollCb,
502
+ },
503
+ );
504
+ completion = {
505
+ matched: stallResult.matched,
506
+ exitCode: stallResult.exitCode,
507
+ stallDetected: stallResult.stallDetected,
508
+ restarts: stallResult.restarts,
509
+ };
510
+ } catch (stallErr) {
511
+ if (stallErr.code === "STALL_EXHAUSTED") {
512
+ completion = {
513
+ matched: false,
514
+ exitCode: null,
515
+ stallExhausted: true,
516
+ restarts: stallErr.restarts,
517
+ };
518
+ } else {
519
+ throw stallErr;
520
+ }
521
+ }
522
+ } else {
523
+ // 기존 waitForCompletion 경로
524
+ if (d.logPath) pollOpts.logPath = d.logPath;
525
+ completion = await waitForCompletion(sessionName, d.paneId || d.paneName, d.token, timeoutSec, pollOpts);
276
526
  }
277
527
 
278
528
  const output = completion.matched
@@ -287,6 +537,8 @@ async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progr
287
537
  matched: completion.matched,
288
538
  exitCode: completion.exitCode,
289
539
  sessionDead: completion.sessionDead || false,
540
+ stallDetected: completion.stallDetected || false,
541
+ stallExhausted: completion.stallExhausted || false,
290
542
  });
291
543
  }
292
544
 
@@ -357,6 +609,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
357
609
  progressive = true,
358
610
  dashboard = false,
359
611
  dashboardLayout = "single",
612
+ stallDetect,
360
613
  } = opts;
361
614
 
362
615
  mkdirSync(RESULT_DIR, { recursive: true });
@@ -365,15 +618,12 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
365
618
  let tui = null;
366
619
  const resolvedLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
367
620
  if (dashboard && process.stdout.isTTY) {
368
- const dashOpts = {
621
+ tui = createLogDashboard({
369
622
  stream: process.stdout,
370
623
  input: process.stdin,
371
624
  refreshMs: 200,
372
625
  layout: resolvedLayout,
373
- };
374
- tui = resolvedLayout === "lite"
375
- ? createLiteDashboard(dashOpts)
376
- : createLogDashboard(dashOpts);
626
+ });
377
627
  tui.setStartTime(Date.now());
378
628
  // 초기 워커 상태 등록
379
629
  for (let i = 0; i < assignments.length; i++) {
@@ -429,7 +679,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
429
679
  ? await dispatchProgressive(sessionName, assignments, { layout, safeProgress, dashboardLayout })
430
680
  : dispatchBatch(sessionName, assignments, { layout, safeProgress, dashboardLayout });
431
681
 
432
- const results = await awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec);
682
+ const results = await awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec, stallDetect);
433
683
  const collected = collectResults(results);
434
684
 
435
685
  // 완료 시 TUI에 최종 상태 반영 후 닫기
@@ -606,19 +856,12 @@ export function ensureWtProfile(workerCount = 2) {
606
856
  * @param {number} [workerCount=2]
607
857
  * @returns {boolean} 성공 여부
608
858
  */
609
- let _wtAvailable = null;
610
- function isWtAvailable() {
611
- if (_wtAvailable !== null) return _wtAvailable;
612
- if (!process.env.WT_SESSION) { _wtAvailable = false; return false; }
613
- try { execSync("where wt.exe", { stdio: "ignore" }); _wtAvailable = true; } catch { _wtAvailable = false; }
614
- return _wtAvailable;
615
- }
616
-
617
859
  export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
618
860
  // 보안: sessionName 셸 주입 방지 — 영숫자, 하이픈, 언더스코어만 허용
619
861
  const safeName = String(sessionName).replace(/[^a-zA-Z0-9_\-]/g, "");
620
862
  sessionName = safeName || "tfx-session";
621
- if (!isWtAvailable()) return false;
863
+ if (!process.env.WT_SESSION) return false;
864
+ try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
622
865
  ensureWtProfile(workerCount);
623
866
  try {
624
867
  const child = spawn("wt.exe", [
@@ -627,45 +870,37 @@ export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
627
870
  "--", "psmux", "attach", "-t", sessionName,
628
871
  ], { detached: true, stdio: "ignore" });
629
872
  child.unref();
630
- // v7.2: mf up 제거 WT window/process로 attach하므로 포커스 이동 불필요
873
+ try { spawn("wt.exe", ["-w", "0", "mf", "up"], { detached: true, stdio: "ignore" }).unref(); } catch { /* 무시 */ }
631
874
  return true;
632
875
  } catch { return false; }
633
876
  }
634
877
 
635
- export function buildDashboardAttachArgs(sessionName, dashboardLayout = "single", workerCount = 2, dashboardAnchor = "window") {
636
- const safeName = String(sessionName).replace(/[^a-zA-Z0-9_\-]/g, "") || "tfx-session";
637
- const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, workerCount);
638
- const resolvedDashboardAnchor = normalizeDashboardAnchor(dashboardAnchor);
639
- const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
640
- const viewerArgs = [
641
- "--profile", "triflux",
642
- "--title", `▲ ${safeName}`,
643
- "--", "node", viewerPath, "--session", safeName, "--result-dir", RESULT_DIR, "--layout", resolvedDashboardLayout,
644
- ];
645
-
646
- if (resolvedDashboardAnchor === "tab") {
647
- return ["-w", "0", "nt", ...viewerArgs];
648
- }
649
- return ["-w", "new", ...viewerArgs];
650
- }
651
-
652
878
  /**
653
879
  * v7.0: psmux 세션을 WT 탭에 attach (대시보드 + 워커 전체 뷰)
654
880
  * @param {string} sessionName
655
881
  * @param {number} workerCount
656
882
  * @param {string} [dashboardLayout='single']
657
883
  * @param {number} [dashboardSize=0.50] — 대시보드 분할 비율 (0.2~0.8)
658
- * @deprecated dashboardSize — anchor=window|tab 모드에서는 무시됨
659
- * @param {string} [dashboardAnchor='window'] — dashboard anchor 정책(window|tab)
660
884
  * @returns {boolean}
661
885
  */
662
- export function attachDashboardTab(sessionName, workerCount = 2, dashboardLayout = "single", dashboardSize = 0.40, dashboardAnchor = "window") {
663
- if (!isWtAvailable()) return false;
886
+ export function attachDashboardTab(sessionName, workerCount = 2, dashboardLayout = "single", dashboardSize = 0.40) {
887
+ try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
664
888
  ensureWtProfile(workerCount);
889
+ const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, workerCount);
890
+
891
+ // v7.1.3: 대시보드만 스플릿 (psmux attach 대신 tui-viewer 직접 실행)
892
+ // raw CLI 출력은 사용자에게 불필요 — 대시보드 로그만 표시
893
+ const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
894
+ const sizeStr = String(Math.round(dashboardSize * 100) / 100);
665
895
  try {
666
- const args = buildDashboardAttachArgs(sessionName, dashboardLayout, workerCount, dashboardAnchor);
667
- const child = spawn("wt.exe", args, { detached: true, stdio: "ignore" });
896
+ const child = spawn("wt.exe", [
897
+ "-w", "0", "sp", "-H", "-s", sizeStr,
898
+ "--profile", "triflux",
899
+ "--title", `▲ ${sessionName}`,
900
+ "--", "node", viewerPath, "--session", sessionName, "--result-dir", RESULT_DIR, "--layout", resolvedDashboardLayout,
901
+ ], { detached: true, stdio: "ignore" });
668
902
  child.unref();
903
+ try { spawn("wt.exe", ["-w", "0", "mf", "up"], { detached: true, stdio: "ignore" }).unref(); } catch {}
669
904
  return true;
670
905
  } catch { return false; }
671
906
  }
@@ -704,7 +939,6 @@ export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
704
939
  * @param {number} [opts.progressIntervalSec=0]
705
940
  * @param {boolean} [opts.autoAttach=false] — Windows Terminal 자동 attach
706
941
  * @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
707
- * @param {string} [opts.dashboardAnchor='window'] — dashboard anchor 정책(window|tab)
708
942
  * @param {AbortSignal} [opts.signal] — abort 시 자동 세션 정리
709
943
  * @param {number} [opts.maxIdleSec=0] — 유휴 시 자동 정리 (0=비활성)
710
944
  * @returns {Promise<{
@@ -724,7 +958,6 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
724
958
  autoAttach = false,
725
959
  dashboard = false,
726
960
  dashboardSize = 0.40,
727
- dashboardAnchor = "window",
728
961
  signal,
729
962
  maxIdleSec = 0,
730
963
  ...runOpts
@@ -746,7 +979,6 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
746
979
  assignments.length,
747
980
  event.dashboardLayout || resolveDashboardLayout(headlessOpts.dashboardLayout, assignments.length),
748
981
  dashboardSize,
749
- dashboardAnchor,
750
982
  );
751
983
  } else {
752
984
  autoAttachTerminal(sessionName, {}, assignments.length);
@@ -20,6 +20,7 @@ import { basename, dirname, join } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
  import { randomUUID } from 'node:crypto';
22
22
  import { isPidAlive } from '../lib/process-utils.mjs';
23
+ import { IS_WINDOWS } from '../platform.mjs';
23
24
 
24
25
  const TEAM_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
25
26
  const CLAUDE_HOME = join(homedir(), '.claude');
@@ -62,7 +63,7 @@ function atomicWriteJson(path, value) {
62
63
  renameSync(tmp, path);
63
64
  } catch (e) {
64
65
  // Windows NTFS: 대상 파일 존재 시 rename 실패 가능 → 삭제 후 재시도
65
- if (process.platform === 'win32' && (e.code === 'EPERM' || e.code === 'EEXIST')) {
66
+ if (IS_WINDOWS && (e.code === 'EPERM' || e.code === 'EEXIST')) {
66
67
  try { unlinkSync(path); } catch {}
67
68
  renameSync(tmp, path);
68
69
  } else {
@@ -5,6 +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 "../platform.mjs";
8
9
 
9
10
  const PSMUX_BIN = (() => {
10
11
  if (process.env.PSMUX_BIN) return process.env.PSMUX_BIN;
@@ -14,7 +15,7 @@ const PSMUX_BIN = (() => {
14
15
  return "psmux";
15
16
  } catch { /* not in PATH */ }
16
17
  // Windows 기본 설치 경로 탐색
17
- if (process.platform === "win32") {
18
+ if (IS_WINDOWS) {
18
19
  const candidates = [
19
20
  join(process.env.LOCALAPPDATA || "", "psmux", "psmux.exe"),
20
21
  join(process.env.APPDATA || "", "npm", "psmux.cmd"),
@@ -28,7 +29,6 @@ const PSMUX_BIN = (() => {
28
29
  return "psmux"; // 최종 fallback — 원래대로
29
30
  })();
30
31
  const GIT_BASH = process.env.GIT_BASH_PATH || "C:\\Program Files\\Git\\bin\\bash.exe";
31
- const IS_WINDOWS = process.platform === "win32";
32
32
 
33
33
  /** Windows psmux 세션의 기본 셸을 PowerShell로 강제한다 (pwsh7 우선, ps5 fallback). */
34
34
  const PWSH_BIN = (() => {
@@ -881,7 +881,7 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
881
881
  const token = randomToken(paneName);
882
882
  const safeCommand = wrapCliForBash(commandText);
883
883
  // CP949 등 non-UTF-8 codepage 환경에서 CLI stdout이 깨지는 문제 방지 (belt-and-suspenders)
884
- const chcpPrefix = process.platform === "win32" ? "chcp 65001 > $null; " : "";
884
+ const chcpPrefix = IS_WINDOWS ? "chcp 65001 > $null; " : "";
885
885
  const wrapped = `${chcpPrefix}try { ${safeCommand} } finally { $trifluxExit = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 }; Write-Output "${COMPLETION_PREFIX}${token}:$trifluxExit" }`;
886
886
 
887
887
  sendLiteralToPane(pane.paneId, wrapped, true);
package/hub/tray.mjs CHANGED
@@ -4,9 +4,10 @@ import _SysTrayModule from "systray2";
4
4
  const SysTray = _SysTrayModule.default || _SysTrayModule;
5
5
  import { exec } from "node:child_process";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
- import { homedir } from "node:os";
8
- import { join, resolve } from "node:path";
9
- import { fileURLToPath } from "node:url";
7
+ import { homedir } from "node:os";
8
+ import { join, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { IS_WINDOWS } from "./platform.mjs";
10
11
 
11
12
  const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
12
13
  const DEFAULT_HUB_PORT = "27888";
@@ -311,10 +312,10 @@ async function shutdown(reason = "shutdown") {
311
312
  }
312
313
  }
313
314
 
314
- export async function startTray() {
315
- if (process.platform !== "win32") {
316
- throw new Error("tray command is only supported on Windows.");
317
- }
315
+ export async function startTray() {
316
+ if (!IS_WINDOWS) {
317
+ throw new Error("tray command is only supported on Windows.");
318
+ }
318
319
 
319
320
  systray = new SysTray({
320
321
  menu,