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.
- package/bin/triflux.mjs +40 -1
- package/hub/lib/process-utils.mjs +86 -0
- package/hub/server.mjs +48 -1
- package/hub/team/ansi.mjs +161 -19
- package/hub/team/backend.mjs +1 -1
- package/hub/team/cli/commands/start/index.mjs +3 -2
- package/hub/team/cli/commands/start/parse-args.mjs +9 -0
- package/hub/team/cli/commands/start/start-headless.mjs +6 -3
- package/hub/team/cli/help.mjs +2 -0
- package/hub/team/dashboard-layout.mjs +31 -0
- package/hub/team/headless.mjs +146 -33
- package/hub/team/psmux.mjs +174 -7
- package/hub/team/tui-viewer.mjs +354 -90
- package/hub/team/tui.mjs +856 -67
- package/package.json +1 -1
- package/scripts/remote-spawn.mjs +92 -12
- package/scripts/tfx-route.sh +17 -8
package/hub/team/headless.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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)
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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,
|
|
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)
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
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:
|
|
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",
|
|
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
|
-
|
|
585
|
-
|
|
690
|
+
const headlessOpts = dashboard
|
|
691
|
+
? { ...runOpts, dashboard: true }
|
|
692
|
+
: { ...runOpts };
|
|
586
693
|
|
|
587
694
|
// autoAttach를 session_created 시점에 트리거 (CLI 실행 전에 터미널 열림)
|
|
588
|
-
const userOnProgress =
|
|
695
|
+
const userOnProgress = headlessOpts.onProgress;
|
|
589
696
|
let terminalAttached = false;
|
|
590
|
-
|
|
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(
|
|
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,
|
|
717
|
+
const { results } = await runHeadless(sessionName, assignments, interactiveRunOpts);
|
|
605
718
|
|
|
606
719
|
// Phase 2: 세션을 유지하고 interactive handle 반환
|
|
607
720
|
// Fix P2: paneId를 dispatches에 포함 (snapshots에서 필요)
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 {
|