triflux 8.0.0 → 8.2.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.
@@ -21,6 +21,8 @@ import {
21
21
  } from "./psmux.mjs";
22
22
  import { HANDOFF_INSTRUCTION_SHORT, processHandoff } from "./handoff.mjs";
23
23
  import { getBackend } from "./backend.mjs";
24
+ import { resolveDashboardLayout } from "./dashboard-layout.mjs";
25
+ import { createLogDashboard } from "./tui.mjs";
24
26
 
25
27
  const RESULT_DIR = join(tmpdir(), "tfx-headless");
26
28
 
@@ -92,11 +94,9 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
92
94
  const promptFile = join(RESULT_DIR, "prompt-" + randomUUID().slice(0, 8) + ".txt").replace(/\\/g, "/");
93
95
  writeFileSync(promptFile, fullPrompt, "utf8");
94
96
 
95
- const cls = "Clear-Host; ";
96
-
97
97
  const backend = getBackend(resolvedCli);
98
98
  const promptExpr = `(Get-Content -Raw '${promptFile}')`;
99
- return `${cls}${backend.buildArgs(promptExpr, resultFile, { ...opts, model })}`;
99
+ return backend.buildArgs(promptExpr, resultFile, { ...opts, model });
100
100
  }
101
101
 
102
102
  /**
@@ -114,10 +114,23 @@ function readResult(resultFile, paneId) {
114
114
  }
115
115
 
116
116
  /** progressive 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가하며 dispatch */
117
- async function dispatchProgressive(sessionName, assignments, layout, safeProgress) {
117
+ async function dispatchProgressive(sessionName, assignments, opts = {}) {
118
+ const {
119
+ layout,
120
+ safeProgress,
121
+ dashboardLayout = "single",
122
+ } = opts;
123
+ const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
118
124
  const session = createPsmuxSession(sessionName, { layout, paneCount: 1 });
119
125
  applyTrifluxTheme(sessionName);
120
- if (safeProgress) safeProgress({ type: "session_created", sessionName, panes: session.panes });
126
+ if (safeProgress) {
127
+ safeProgress({
128
+ type: "session_created",
129
+ sessionName,
130
+ panes: session.panes,
131
+ dashboardLayout: resolvedDashboardLayout,
132
+ });
133
+ }
121
134
 
122
135
  // dashboard: 워커 pane을 먼저 생성한 후 pane 0에 대시보드를 실행
123
136
  // (listPanes로 워커 감지가 가능하려면 워커 pane이 먼저 존재해야 함)
@@ -133,16 +146,12 @@ async function dispatchProgressive(sessionName, assignments, layout, safeProgres
133
146
  : `${brand.emoji} ${resolvedCli}-${i + 1}`;
134
147
 
135
148
  let newPaneId;
136
- if (i === 0) {
137
- // 번째 워커: lead pane 사용
138
- newPaneId = `${sessionName}:0.0`;
139
- } else {
140
- // 2번째+: split-window로 추가
141
- newPaneId = psmuxExec([
142
- "split-window", "-t", sessionName, "-P", "-F",
143
- "#{session_name}:#{window_index}.#{pane_index}",
144
- ]);
145
- }
149
+ // 모든 워커를 split-window로 생성 (lead pane index 0은 비워둠)
150
+ // tui-viewer가 index 0을 건너뛰므로, 워커는 항상 index >= 1에 배치
151
+ newPaneId = psmuxExec([
152
+ "split-window", "-t", sessionName, "-P", "-F",
153
+ "#{session_name}:#{window_index}.#{pane_index}",
154
+ ]);
146
155
 
147
156
  // 타이틀 설정 (이모지 포함)
148
157
  try { psmuxExec(["select-pane", "-t", newPaneId, "-T", paneTitle]); } catch { /* 무시 */ }
@@ -171,13 +180,26 @@ async function dispatchProgressive(sessionName, assignments, layout, safeProgres
171
180
  }
172
181
 
173
182
  /** 기존 batch 모드: 모든 pane을 한 번에 생성하여 dispatch */
174
- function dispatchBatch(sessionName, assignments, layout, safeProgress) {
183
+ function dispatchBatch(sessionName, assignments, opts = {}) {
184
+ const {
185
+ layout,
186
+ safeProgress,
187
+ dashboardLayout = "single",
188
+ } = opts;
175
189
  const paneCount = assignments.length + 1;
190
+ const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
176
191
  // A2b fix: 2x2 레이아웃은 최대 4 pane — 초과 시 tiled로 자동 전환
177
192
  const effectiveLayout = (layout === "2x2" && paneCount > 4) ? "tiled" : layout;
178
193
  const session = createPsmuxSession(sessionName, { layout: effectiveLayout, paneCount });
179
194
  applyTrifluxTheme(sessionName);
180
- if (safeProgress) safeProgress({ type: "session_created", sessionName, panes: session.panes });
195
+ if (safeProgress) {
196
+ safeProgress({
197
+ type: "session_created",
198
+ sessionName,
199
+ panes: session.panes,
200
+ dashboardLayout: resolvedDashboardLayout,
201
+ });
202
+ }
181
203
 
182
204
  return assignments.map((assignment, i) => {
183
205
  const paneName = `worker-${i + 1}`;
@@ -301,6 +323,7 @@ function collectResults(results) {
301
323
  * @param {(event: object) => void} [opts.onProgress] — 진행 콜백
302
324
  * @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
303
325
  * @param {boolean} [opts.progressive=true] — true면 pane을 하나씩 split-window로 추가 (실시간 스플릿)
326
+ * @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
304
327
  * @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
305
328
  */
306
329
  export async function runHeadless(sessionName, assignments, opts = {}) {
@@ -311,22 +334,100 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
311
334
  progressIntervalSec = 0,
312
335
  progressive = true,
313
336
  dashboard = false,
337
+ dashboardLayout = "single",
314
338
  } = opts;
315
339
 
316
340
  mkdirSync(RESULT_DIR, { recursive: true });
317
341
 
342
+ // in-process TUI: dashboard=true이고 stdout이 TTY일 때 직접 구동
343
+ let tui = null;
344
+ const resolvedLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
345
+ if (dashboard && process.stdout.isTTY) {
346
+ tui = createLogDashboard({
347
+ stream: process.stdout,
348
+ input: process.stdin,
349
+ refreshMs: 200,
350
+ layout: resolvedLayout,
351
+ });
352
+ tui.setStartTime(Date.now());
353
+ // 초기 워커 상태 등록
354
+ for (let i = 0; i < assignments.length; i++) {
355
+ const a = assignments[i];
356
+ tui.updateWorker(`worker-${i + 1}`, {
357
+ cli: a.cli || "codex",
358
+ role: a.role || "",
359
+ status: "pending",
360
+ progress: 0,
361
+ });
362
+ }
363
+ }
364
+
365
+ // per-worker state feed: onProgress 이벤트 → tui.updateWorker()
366
+ function feedTui(event) {
367
+ if (!tui) return;
368
+ const { type, paneName, cli, snapshot, matched, exitCode } = event;
369
+ if (!paneName) return;
370
+
371
+ if (type === "progress" && snapshot) {
372
+ tui.updateWorker(paneName, {
373
+ cli: cli || "codex",
374
+ status: "running",
375
+ snapshot: snapshot.split("\n").at(-1) || "",
376
+ summary: snapshot.split("\n").at(-1) || "",
377
+ detail: snapshot,
378
+ progress: 0.5,
379
+ });
380
+ } else if (type === "completed") {
381
+ const status = matched && exitCode === 0 ? "completed" : "failed";
382
+ tui.updateWorker(paneName, {
383
+ cli: cli || "codex",
384
+ status,
385
+ progress: 1,
386
+ });
387
+ } else if (type === "worker_added") {
388
+ tui.updateWorker(paneName, {
389
+ cli: cli || "codex",
390
+ status: "running",
391
+ progress: 0.05,
392
+ });
393
+ }
394
+ }
395
+
318
396
  // onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
319
- const safeProgress = onProgress
320
- ? (event) => { try { onProgress(event); } catch { /* 콜백 예외 삼킴 */ } }
321
- : null;
397
+ const combinedProgress = (event) => {
398
+ feedTui(event);
399
+ if (onProgress) { try { onProgress(event); } catch { /* 콜백 예외 삼킴 */ } }
400
+ };
401
+ const safeProgress = (event) => { try { combinedProgress(event); } catch { /* 삼킴 */ } };
322
402
 
323
403
  const dispatches = progressive
324
- ? await dispatchProgressive(sessionName, assignments, layout, safeProgress)
325
- : dispatchBatch(sessionName, assignments, layout, safeProgress);
404
+ ? await dispatchProgressive(sessionName, assignments, { layout, safeProgress, dashboardLayout })
405
+ : dispatchBatch(sessionName, assignments, { layout, safeProgress, dashboardLayout });
326
406
 
327
407
  const results = await awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec);
408
+ const collected = collectResults(results);
409
+
410
+ // 완료 시 TUI에 최종 상태 반영 후 닫기
411
+ if (tui) {
412
+ for (const r of collected) {
413
+ tui.updateWorker(r.paneName, {
414
+ cli: r.cli,
415
+ role: r.role || "",
416
+ status: r.handoff?.status === "failed" ? "failed" : "completed",
417
+ handoff: r.handoff,
418
+ summary: r.handoff?.verdict || (r.matched ? "completed" : "failed"),
419
+ detail: r.output,
420
+ progress: 1,
421
+ elapsed: Math.round((Date.now() - (tui._startedAt || Date.now())) / 1000),
422
+ });
423
+ }
424
+ tui.render();
425
+ // 최종 화면을 잠깐 유지 후 닫기
426
+ await new Promise((r) => setTimeout(r, 1500));
427
+ tui.close();
428
+ }
328
429
 
329
- return { sessionName, results: collectResults(results) };
430
+ return { sessionName, results: collected };
330
431
  }
331
432
 
332
433
  /**
@@ -503,21 +604,25 @@ export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
503
604
  * v7.0: psmux 세션을 WT 탭에 attach (대시보드 + 워커 전체 뷰)
504
605
  * @param {string} sessionName
505
606
  * @param {number} workerCount
607
+ * @param {string} [dashboardLayout='single']
608
+ * @param {number} [dashboardSize=0.50] — 대시보드 분할 비율 (0.2~0.8)
506
609
  * @returns {boolean}
507
610
  */
508
- export function attachDashboardTab(sessionName, workerCount = 2) {
611
+ export function attachDashboardTab(sessionName, workerCount = 2, dashboardLayout = "single", dashboardSize = 0.40) {
509
612
  try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
510
613
  ensureWtProfile(workerCount);
614
+ const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, workerCount);
511
615
 
512
616
  // v7.1.3: 대시보드만 스플릿 (psmux attach 대신 tui-viewer 직접 실행)
513
617
  // raw CLI 출력은 사용자에게 불필요 — 대시보드 로그만 표시
514
618
  const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
619
+ const sizeStr = String(Math.round(dashboardSize * 100) / 100);
515
620
  try {
516
621
  const child = spawn("wt.exe", [
517
- "-w", "0", "sp", "-H", "-s", "0.30",
622
+ "-w", "0", "sp", "-H", "-s", sizeStr,
518
623
  "--profile", "triflux",
519
624
  "--title", `▲ ${sessionName}`,
520
- "--", "node", viewerPath, "--session", sessionName,
625
+ "--", "node", viewerPath, "--session", sessionName, "--result-dir", RESULT_DIR, "--layout", resolvedDashboardLayout,
521
626
  ], { detached: true, stdio: "ignore" });
522
627
  child.unref();
523
628
  try { spawn("wt.exe", ["-w", "0", "mf", "up"], { detached: true, stdio: "ignore" }).unref(); } catch {}
@@ -558,6 +663,7 @@ export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
558
663
  * @param {(event: object) => void} [opts.onProgress]
559
664
  * @param {number} [opts.progressIntervalSec=0]
560
665
  * @param {boolean} [opts.autoAttach=false] — Windows Terminal 자동 attach
666
+ * @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
561
667
  * @param {AbortSignal} [opts.signal] — abort 시 자동 세션 정리
562
668
  * @param {number} [opts.maxIdleSec=0] — 유휴 시 자동 정리 (0=비활성)
563
669
  * @returns {Promise<{
@@ -576,32 +682,39 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
576
682
  const {
577
683
  autoAttach = false,
578
684
  dashboard = false,
685
+ dashboardSize = 0.40,
579
686
  signal,
580
687
  maxIdleSec = 0,
581
688
  ...runOpts
582
689
  } = opts;
583
-
584
- // dashboard 옵션을 runHeadless에 전달
585
- if (dashboard) runOpts.dashboard = true;
690
+ const headlessOpts = dashboard
691
+ ? { ...runOpts, dashboard: true }
692
+ : { ...runOpts };
586
693
 
587
694
  // autoAttach를 session_created 시점에 트리거 (CLI 실행 전에 터미널 열림)
588
- const userOnProgress = runOpts.onProgress;
695
+ const userOnProgress = headlessOpts.onProgress;
589
696
  let terminalAttached = false;
590
- runOpts.onProgress = (event) => {
697
+ const onProgress = (event) => {
591
698
  if (autoAttach && event.type === "session_created" && !terminalAttached) {
592
699
  terminalAttached = true;
593
700
  if (dashboard) {
594
701
  // v7.0: psmux attach로 대시보드+워커 전체 세션을 WT 탭에 표시
595
- attachDashboardTab(sessionName, assignments.length);
702
+ attachDashboardTab(
703
+ sessionName,
704
+ assignments.length,
705
+ event.dashboardLayout || resolveDashboardLayout(headlessOpts.dashboardLayout, assignments.length),
706
+ dashboardSize,
707
+ );
596
708
  } else {
597
709
  autoAttachTerminal(sessionName, {}, assignments.length);
598
710
  }
599
711
  }
600
712
  if (userOnProgress) userOnProgress(event);
601
713
  };
714
+ const interactiveRunOpts = { ...headlessOpts, onProgress };
602
715
 
603
716
  // Phase 1: 세션 생성 → 즉시 터미널 팝업 → dispatch → 대기 → 결과 수집
604
- const { results } = await runHeadless(sessionName, assignments, runOpts);
717
+ const { results } = await runHeadless(sessionName, assignments, interactiveRunOpts);
605
718
 
606
719
  // Phase 2: 세션을 유지하고 interactive handle 반환
607
720
  // Fix P2: paneId를 dispatches에 포함 (snapshots에서 필요)
@@ -2,7 +2,7 @@
2
2
  // 의존성: child_process, fs, os, path (Node.js 내장)만 사용
3
3
  import childProcess from "node:child_process";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { tmpdir } from "node:os";
5
+ import { tmpdir, homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
 
8
8
  const PSMUX_BIN = process.env.PSMUX_BIN || "psmux";
@@ -26,7 +26,7 @@ function quoteArg(value) {
26
26
  }
27
27
 
28
28
  function sanitizePathPart(value) {
29
- return String(value).replace(/[<>:"/\\|?*\u0000-\u001f]/gu, "_");
29
+ return String(value).replace(/[<>:"/\\|?*\u0000-\u001f']/gu, "_");
30
30
  }
31
31
 
32
32
  function toPaneTitle(index) {
@@ -282,7 +282,7 @@ function refreshCaptureSnapshot(sessionName, paneNameOrTarget) {
282
282
  const paneName = pane.title || paneNameOrTarget;
283
283
  const logPath = getCaptureLogPath(sessionName, paneName);
284
284
  mkdirSync(getCaptureSessionDir(sessionName), { recursive: true });
285
- const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p"]);
285
+ const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p", "-S", "-"]);
286
286
  writeFileSync(logPath, snapshot, "utf8");
287
287
  return { paneId: pane.paneId, paneName, logPath, snapshot };
288
288
  }
@@ -453,16 +453,145 @@ export function createPsmuxSession(sessionName, opts = {}) {
453
453
  return { sessionName, panes };
454
454
  }
455
455
 
456
+ /**
457
+ * psmux 세션의 모든 pane PID를 수집
458
+ * @param {string} sessionName
459
+ * @returns {number[]}
460
+ */
461
+ function collectPanePids(sessionName) {
462
+ try {
463
+ const output = psmuxExec([
464
+ "list-panes", "-t", sessionName, "-F", "#{pane_pid}",
465
+ ]);
466
+ return output
467
+ .split(/\r?\n/)
468
+ .map((l) => Number.parseInt(l.trim(), 10))
469
+ .filter((pid) => Number.isFinite(pid) && pid > 0);
470
+ } catch {
471
+ return [];
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Windows 프로세스 트리 강제 종료 (taskkill /T /F)
477
+ * @param {number} pid
478
+ */
479
+ function killProcessTree(pid) {
480
+ if (!IS_WINDOWS || !pid) return;
481
+ try {
482
+ childProcess.execSync(`taskkill /T /F /PID ${pid}`, {
483
+ stdio: "ignore",
484
+ timeout: 5000,
485
+ });
486
+ } catch {
487
+ // 이미 종료된 프로세스 — 무시
488
+ }
489
+ }
490
+
491
+ /**
492
+ * 세션의 모든 pane에서 pipe-pane 캡처를 해제한다.
493
+ * pipe-pane을 인자 없이 호출하면 psmux가 reader 프로세스에 EOF를 보내 정상 종료시킨다.
494
+ * @param {string} sessionName
495
+ * @param {string[]} paneIds — collectSessionPanes() 결과
496
+ */
497
+ function disableAllPipeCaptures(sessionName, paneIds) {
498
+ for (const paneId of paneIds) {
499
+ try {
500
+ psmuxExec(["pipe-pane", "-t", paneId]);
501
+ } catch {
502
+ // pane이 이미 죽었거나 pipe가 없으면 무시
503
+ }
504
+ }
505
+ }
506
+
507
+ /**
508
+ * 세션과 관련된 고아 pipe-pane 헬퍼 프로세스를 찾아 종료한다.
509
+ * pipe-pane disable 후에도 reader가 종료되지 않는 경우의 안전망.
510
+ * @param {string} sessionName
511
+ */
512
+ function killOrphanPipeHelpers(sessionName) {
513
+ if (!IS_WINDOWS) return;
514
+ const safeSession = sanitizePathPart(sessionName);
515
+ try {
516
+ const output = childProcess.execSync(
517
+ `powershell -NoProfile -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pipe-pane-capture' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
518
+ { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] },
519
+ );
520
+ const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0);
521
+ for (const pid of pids) {
522
+ killProcessTree(pid);
523
+ }
524
+ } catch {
525
+ // WMI 조회 실패 — 무시
526
+ }
527
+ }
528
+
529
+ /**
530
+ * 세션이 spawn한 CLI(codex/gemini)의 고아 MCP 서버 프로세스를 찾아 종료한다.
531
+ * headless 워커가 codex/gemini를 실행하면 MCP 서버(node.exe)가 자식으로 생성되는데,
532
+ * 부모가 죽어도 Windows에서는 자식이 자동 종료되지 않아 고아가 된다.
533
+ * @param {string} sessionName
534
+ */
535
+ function killOrphanMcpProcesses(sessionName) {
536
+ if (!IS_WINDOWS) return;
537
+ const safeSession = sanitizePathPart(sessionName);
538
+
539
+ // Hub PID 보호 — Hub 프로세스를 고아로 잘못 식별하지 않도록
540
+ let hubPid = null;
541
+ try {
542
+ const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
543
+ if (existsSync(hubPidPath)) {
544
+ const hubInfo = JSON.parse(readFileSync(hubPidPath, "utf8"));
545
+ hubPid = Number(hubInfo?.pid);
546
+ }
547
+ } catch {}
548
+
549
+ try {
550
+ // 세션 결과 디렉토리 패턴으로 MCP 서버 프로세스 식별
551
+ const output = childProcess.execSync(
552
+ `powershell -NoProfile -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
553
+ { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] },
554
+ );
555
+ const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0 && p !== hubPid);
556
+ for (const pid of pids) {
557
+ killProcessTree(pid);
558
+ }
559
+ } catch {
560
+ // WMI 조회 실패 — 무시
561
+ }
562
+ }
563
+
456
564
  /**
457
565
  * psmux 세션 종료
566
+ * 순서: pipe-pane 해제 → pane 프로세스 트리 정리 → 세션 종료 → 고아 정리
458
567
  * @param {string} sessionName
459
568
  */
460
569
  export function killPsmuxSession(sessionName) {
570
+ // 1. pipe-pane 캡처 해제 — reader 프로세스에 EOF 전송하여 정상 종료 유도
571
+ let paneIds = [];
572
+ try {
573
+ paneIds = collectSessionPanes(sessionName);
574
+ } catch {
575
+ // 세션이 이미 죽었으면 pane 목록 수집 불가 — 계속 진행
576
+ }
577
+ disableAllPipeCaptures(sessionName, paneIds);
578
+
579
+ // 2. pane 프로세스 트리 강제 종료 (MCP 서버 포함)
580
+ const pids = collectPanePids(sessionName);
581
+ for (const pid of pids) {
582
+ killProcessTree(pid);
583
+ }
584
+
585
+ // 3. psmux 세션 자체 종료
461
586
  try {
462
587
  psmuxExec(["kill-session", "-t", sessionName], { stdio: "ignore" });
463
588
  } catch {
464
589
  // 이미 종료된 세션 — 무시
465
590
  }
591
+
592
+ // 4. 고아 프로세스 정리 (pipe-pane 헬퍼 + MCP 서버)
593
+ killOrphanPipeHelpers(sessionName);
594
+ killOrphanMcpProcesses(sessionName);
466
595
  }
467
596
 
468
597
  /**
@@ -501,7 +630,7 @@ export function listPsmuxSessions() {
501
630
  */
502
631
  export function capturePsmuxPane(target, lines = 5) {
503
632
  try {
504
- const full = psmuxExec(["capture-pane", "-t", target, "-p"]);
633
+ const full = psmuxExec(["capture-pane", "-t", target, "-p", "-S", "-"]);
505
634
  const nonEmpty = full.split("\n").filter((line) => line.trim() !== "");
506
635
  return nonEmpty.slice(-lines).join("\n");
507
636
  } catch {
@@ -635,7 +764,9 @@ export function startCapture(sessionName, paneNameOrTarget) {
635
764
  */
636
765
  function wrapCliForBash(cmd) {
637
766
  const trimmed = cmd.trimStart();
638
- const isCli = /^(codex|gemini)\b/u.test(trimmed);
767
+ // PowerShell 구문(Clear-Host, Get-Content 등) 또는 completion token이 포함되면 PowerShell 직통
768
+ if (/Clear-Host|Get-Content|__TRIFLUX_DONE__/i.test(trimmed)) return cmd;
769
+ const isCli = /\b(codex|gemini)\b/u.test(trimmed);
639
770
  if (!isCli) return cmd;
640
771
  // 단일 따옴표 이스케이프: ' → '\''
641
772
  const escaped = trimmed.replace(/'/g, "'\\''");
@@ -654,7 +785,7 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
654
785
 
655
786
  const token = randomToken(paneName);
656
787
  const safeCommand = wrapCliForBash(commandText);
657
- const wrapped = `${safeCommand}; $trifluxExit = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 }; Write-Output "${COMPLETION_PREFIX}${token}:$trifluxExit"`;
788
+ const wrapped = `try { ${safeCommand} } finally { $trifluxExit = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 }; Write-Output "${COMPLETION_PREFIX}${token}:$trifluxExit" }`;
658
789
 
659
790
  sendLiteralToPane(pane.paneId, wrapped, true);
660
791
 
@@ -710,7 +841,7 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
710
841
  try {
711
842
  if (opts.logPath) {
712
843
  // logPath 직접 지정 시 — 셸 타이틀 변경과 무관하게 올바른 파일에 기록
713
- const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p"]);
844
+ const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p", "-S", "-"]);
714
845
  writeFileSync(logPath, snapshot, "utf8");
715
846
  } else {
716
847
  refreshCaptureSnapshot(sessionName, pane.paneId);
@@ -779,6 +910,30 @@ export async function waitForCompletion(sessionName, paneNameOrTarget, token, ti
779
910
  "m",
780
911
  );
781
912
  const result = await waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec, opts);
913
+
914
+ // 타이밍 이슈 대응: matched=false인 경우 500ms 대기 후 최종 1회 캡처 재시도
915
+ if (!result.matched && !result.sessionDead && result.logPath) {
916
+ await new Promise((r) => setTimeout(r, 500));
917
+ try {
918
+ const pane = resolvePane(sessionName, paneNameOrTarget);
919
+ const snapshot = psmuxExec(["capture-pane", "-t", pane.paneId, "-p", "-S", "-"]);
920
+ writeFileSync(result.logPath, snapshot, "utf8");
921
+ const content = readCaptureLog(result.logPath);
922
+ const retryMatch = completionRegex.exec(content);
923
+ if (retryMatch) {
924
+ return {
925
+ ...result,
926
+ matched: true,
927
+ match: retryMatch[0],
928
+ token,
929
+ exitCode: Number.parseInt(retryMatch[1], 10),
930
+ };
931
+ }
932
+ } catch {
933
+ // 세션 이미 종료 — 무시
934
+ }
935
+ }
936
+
782
937
  const exitMatch = result.match ? completionRegex.exec(result.match) : null;
783
938
  return {
784
939
  ...result,
@@ -869,6 +1024,18 @@ export function killWorker(sessionName, workerName) {
869
1024
  try {
870
1025
  const { paneId, status } = getWorkerStatus(sessionName, workerName);
871
1026
 
1027
+ // pipe-pane 캡처 해제 — reader 프로세스 정상 종료 유도
1028
+ disablePipeCapture(paneId);
1029
+
1030
+ // pane PID 수집 → 프로세스 트리 정리 (MCP 서버 좀비 방지)
1031
+ try {
1032
+ const pidOutput = psmuxExec(["list-panes", "-t", paneId, "-F", "#{pane_pid}"]);
1033
+ const pid = Number.parseInt(pidOutput.trim(), 10);
1034
+ if (Number.isFinite(pid) && pid > 0) killProcessTree(pid);
1035
+ } catch {
1036
+ // PID 조회 실패 — 아래에서 pane만 정리
1037
+ }
1038
+
872
1039
  // 이미 종료된 워커 → pane 정리만 수행
873
1040
  if (status === "exited") {
874
1041
  try {