@triflux/remote 10.0.0-alpha.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/hub/pipe.mjs +579 -0
- package/hub/public/dashboard.html +355 -0
- package/hub/public/tray-icon.ico +0 -0
- package/hub/public/tray-icon.png +0 -0
- package/hub/server.mjs +1124 -0
- package/hub/store-adapter.mjs +851 -0
- package/hub/store.mjs +897 -0
- package/hub/team/agent-map.json +11 -0
- package/hub/team/ansi.mjs +379 -0
- package/hub/team/backend.mjs +90 -0
- package/hub/team/cli/commands/attach.mjs +37 -0
- package/hub/team/cli/commands/control.mjs +43 -0
- package/hub/team/cli/commands/debug.mjs +74 -0
- package/hub/team/cli/commands/focus.mjs +53 -0
- package/hub/team/cli/commands/interrupt.mjs +36 -0
- package/hub/team/cli/commands/kill.mjs +37 -0
- package/hub/team/cli/commands/list.mjs +24 -0
- package/hub/team/cli/commands/send.mjs +37 -0
- package/hub/team/cli/commands/start/index.mjs +106 -0
- package/hub/team/cli/commands/start/parse-args.mjs +130 -0
- package/hub/team/cli/commands/start/start-headless.mjs +109 -0
- package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
- package/hub/team/cli/commands/start/start-mux.mjs +73 -0
- package/hub/team/cli/commands/start/start-wt.mjs +69 -0
- package/hub/team/cli/commands/status.mjs +87 -0
- package/hub/team/cli/commands/stop.mjs +31 -0
- package/hub/team/cli/commands/task.mjs +30 -0
- package/hub/team/cli/commands/tasks.mjs +13 -0
- package/hub/team/cli/help.mjs +42 -0
- package/hub/team/cli/index.mjs +41 -0
- package/hub/team/cli/manifest.mjs +29 -0
- package/hub/team/cli/render.mjs +30 -0
- package/hub/team/cli/services/attach-fallback.mjs +54 -0
- package/hub/team/cli/services/hub-client.mjs +208 -0
- package/hub/team/cli/services/member-selector.mjs +30 -0
- package/hub/team/cli/services/native-control.mjs +117 -0
- package/hub/team/cli/services/runtime-mode.mjs +62 -0
- package/hub/team/cli/services/state-store.mjs +48 -0
- package/hub/team/cli/services/task-model.mjs +30 -0
- package/hub/team/dashboard-anchor.mjs +14 -0
- package/hub/team/dashboard-layout.mjs +33 -0
- package/hub/team/dashboard-open.mjs +153 -0
- package/hub/team/dashboard.mjs +274 -0
- package/hub/team/handoff.mjs +303 -0
- package/hub/team/headless.mjs +1149 -0
- package/hub/team/native-supervisor.mjs +392 -0
- package/hub/team/native.mjs +649 -0
- package/hub/team/nativeProxy.mjs +681 -0
- package/hub/team/orchestrator.mjs +161 -0
- package/hub/team/pane.mjs +153 -0
- package/hub/team/psmux.mjs +1354 -0
- package/hub/team/routing.mjs +223 -0
- package/hub/team/session.mjs +611 -0
- package/hub/team/shared.mjs +13 -0
- package/hub/team/staleState.mjs +361 -0
- package/hub/team/tui-lite.mjs +380 -0
- package/hub/team/tui-viewer.mjs +463 -0
- package/hub/team/tui.mjs +1245 -0
- package/hub/tools.mjs +554 -0
- package/hub/tray.mjs +376 -0
- package/hub/workers/claude-worker.mjs +475 -0
- package/hub/workers/codex-mcp.mjs +504 -0
- package/hub/workers/delegator-mcp.mjs +1076 -0
- package/hub/workers/factory.mjs +21 -0
- package/hub/workers/gemini-worker.mjs +373 -0
- package/hub/workers/interface.mjs +52 -0
- package/hub/workers/worker-utils.mjs +104 -0
- package/package.json +31 -0
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
// hub/team/headless.mjs — 헤드리스 CLI 오케스트레이션
|
|
2
|
+
// psmux pane에서 CLI를 헤드리스 모드로 실행하고 결과를 수집한다.
|
|
3
|
+
// v5.2.0: 기본 headless 엔진 (runHeadless, runHeadlessWithCleanup)
|
|
4
|
+
// v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
|
|
5
|
+
// 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { execSync, spawn } from "node:child_process";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import {
|
|
13
|
+
createPsmuxSession,
|
|
14
|
+
killPsmuxSession,
|
|
15
|
+
psmuxSessionExists,
|
|
16
|
+
dispatchCommand,
|
|
17
|
+
waitForCompletion,
|
|
18
|
+
capturePsmuxPane,
|
|
19
|
+
startCapture,
|
|
20
|
+
psmuxExec,
|
|
21
|
+
} from "./psmux.mjs";
|
|
22
|
+
import { HANDOFF_INSTRUCTION_SHORT, processHandoff } from "./handoff.mjs";
|
|
23
|
+
import { getBackend } from "./backend.mjs";
|
|
24
|
+
import { resolveDashboardLayout } from "./dashboard-layout.mjs";
|
|
25
|
+
import { createLogDashboard } from "./tui.mjs";
|
|
26
|
+
|
|
27
|
+
const RESULT_DIR = join(tmpdir(), "tfx-headless");
|
|
28
|
+
|
|
29
|
+
// remote-spawn.mjs의 escapePwshSingleQuoted와 동일 — 순환 의존 방지를 위해 인라인
|
|
30
|
+
function escapePwshSingleQuoted(value) {
|
|
31
|
+
return String(value).replace(/'/g, "''");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
|
|
35
|
+
const CLI_BRAND = {
|
|
36
|
+
codex: { emoji: "\u{26AA}", label: "Codex", ansi: "\x1b[97m" }, // ⚪ bright white (codexWhite)
|
|
37
|
+
gemini: { emoji: "\u{1F535}", label: "Gemini", ansi: "\x1b[38;5;39m" }, // 🔵 geminiBlue
|
|
38
|
+
claude: { emoji: "\u{1F7E0}", label: "Claude", ansi: "\x1b[38;2;232;112;64m" }, // 🟠 claudeOrange
|
|
39
|
+
};
|
|
40
|
+
const ANSI_RESET = "\x1b[0m";
|
|
41
|
+
const ANSI_DIM = "\x1b[2m";
|
|
42
|
+
|
|
43
|
+
/** 에이전트 역할명 → CLI 타입 매핑 (단일 소스: agent-map.json) */
|
|
44
|
+
const _require = createRequire(import.meta.url);
|
|
45
|
+
const AGENT_TO_CLI = _require("./agent-map.json");
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 에이전트 역할명 또는 CLI 이름을 CLI 타입("codex"|"gemini"|"claude")으로 해석한다.
|
|
49
|
+
* route_agent()가 적용되지 않는 headless 경로에서 사용.
|
|
50
|
+
* @param {string} agentOrCli — "executor", "codex", "designer" 등
|
|
51
|
+
* @returns {'codex'|'gemini'|'claude'} CLI 타입
|
|
52
|
+
*/
|
|
53
|
+
export function resolveCliType(agentOrCli) {
|
|
54
|
+
return AGENT_TO_CLI[agentOrCli] || agentOrCli;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
|
|
58
|
+
const MCP_PROFILE_HINTS = {
|
|
59
|
+
implement: "You have full filesystem read/write access. Implement changes directly.",
|
|
60
|
+
analyze: "Focus on reading and analyzing the codebase. Prefer analysis over modification.",
|
|
61
|
+
review: "Review the code for quality, security, and correctness.",
|
|
62
|
+
docs: "Focus on documentation and explanation tasks.",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* CLI별 헤드리스 명령 빌더
|
|
67
|
+
* @param {'codex'|'gemini'|'claude'} cli
|
|
68
|
+
* @param {string} prompt — 실행할 프롬프트
|
|
69
|
+
* @param {string} resultFile — 결과 저장 파일 경로
|
|
70
|
+
* @param {object} [opts]
|
|
71
|
+
* @param {boolean} [opts.handoff=true]
|
|
72
|
+
* @param {string} [opts.mcp] — MCP 프로필 ("implement"|"analyze"|"review"|"docs")
|
|
73
|
+
* @param {string} [opts.contextFile] — 컨텍스트 파일 경로 (최대 32KB, UTF-8 안전 절단)
|
|
74
|
+
* @returns {string} PowerShell 명령
|
|
75
|
+
*/
|
|
76
|
+
export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
77
|
+
const { handoff = true, mcp, contextFile, model, cwd } = opts;
|
|
78
|
+
const resolvedCli = resolveCliType(cli);
|
|
79
|
+
|
|
80
|
+
// contextFile 처리: 32KB(32768 bytes) 초과 시 UTF-8 안전 절단
|
|
81
|
+
let contextPrefix = "";
|
|
82
|
+
if (contextFile && existsSync(contextFile)) {
|
|
83
|
+
let ctx = readFileSync(contextFile, "utf8");
|
|
84
|
+
if (Buffer.byteLength(ctx, "utf8") > 32768) {
|
|
85
|
+
ctx = Buffer.from(ctx).subarray(0, 32768).toString("utf8");
|
|
86
|
+
}
|
|
87
|
+
if (ctx.length > 0) {
|
|
88
|
+
contextPrefix = `<prior_context>\n${ctx}\n</prior_context>\n\n`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const mcpHint = mcp && MCP_PROFILE_HINTS[mcp] ? ` [MCP: ${mcp}] ${MCP_PROFILE_HINTS[mcp]}` : "";
|
|
93
|
+
// P2: HANDOFF 지시를 프롬프트에 삽입 (워커가 구조화된 handoff 블록을 출력하도록)
|
|
94
|
+
const handoffHint = handoff ? `\n\n${HANDOFF_INSTRUCTION_SHORT}` : "";
|
|
95
|
+
const fullPrompt = `${contextPrefix}${prompt}${mcpHint}${handoffHint}`;
|
|
96
|
+
|
|
97
|
+
// 보안: 프롬프트를 임시 파일에 쓰고 파일 참조로 전달 (셸 주입 방지)
|
|
98
|
+
if (!existsSync(RESULT_DIR)) mkdirSync(RESULT_DIR, { recursive: true });
|
|
99
|
+
const promptFile = join(RESULT_DIR, "prompt-" + randomUUID().slice(0, 8) + ".txt").replace(/\\/g, "/");
|
|
100
|
+
writeFileSync(promptFile, fullPrompt, "utf8");
|
|
101
|
+
|
|
102
|
+
const backend = getBackend(resolvedCli);
|
|
103
|
+
const promptExpr = `(Get-Content -Raw '${promptFile}')`;
|
|
104
|
+
const backendCommand = backend.buildArgs(promptExpr, resultFile, { ...opts, model });
|
|
105
|
+
const safeCwd = typeof cwd === "string" ? cwd.trim().replace(/[\r\n\x00-\x1f]/g, "") : "";
|
|
106
|
+
if (safeCwd && (safeCwd.startsWith("\\\\") || safeCwd.startsWith("//"))) {
|
|
107
|
+
throw new Error("[headless] UNC 경로는 cwd로 사용할 수 없습니다: " + safeCwd);
|
|
108
|
+
}
|
|
109
|
+
if (!safeCwd) return backendCommand;
|
|
110
|
+
|
|
111
|
+
return `Set-Location -LiteralPath '${escapePwshSingleQuoted(safeCwd)}'; ${backendCommand}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 결과 파일 읽기 (없으면 capture-pane fallback)
|
|
116
|
+
* @param {string} resultFile
|
|
117
|
+
* @param {string} paneId
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
function readResult(resultFile, paneId) {
|
|
121
|
+
if (existsSync(resultFile)) {
|
|
122
|
+
return readFileSync(resultFile, "utf8").trim();
|
|
123
|
+
}
|
|
124
|
+
// fallback: capture-pane (paneId = "tfx:0.1" 형태)
|
|
125
|
+
return capturePsmuxPane(paneId, 30);
|
|
126
|
+
}
|
|
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
|
+
|
|
337
|
+
/** progressive 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가하며 dispatch */
|
|
338
|
+
async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
339
|
+
const {
|
|
340
|
+
layout,
|
|
341
|
+
safeProgress,
|
|
342
|
+
dashboardLayout = "single",
|
|
343
|
+
} = opts;
|
|
344
|
+
const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
|
|
345
|
+
const session = createPsmuxSession(sessionName, { layout, paneCount: 1 });
|
|
346
|
+
applyTrifluxTheme(sessionName);
|
|
347
|
+
if (safeProgress) {
|
|
348
|
+
safeProgress({
|
|
349
|
+
type: "session_created",
|
|
350
|
+
sessionName,
|
|
351
|
+
panes: session.panes,
|
|
352
|
+
dashboardLayout: resolvedDashboardLayout,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// dashboard: 워커 pane을 먼저 생성한 후 pane 0에 대시보드를 실행
|
|
357
|
+
// (listPanes로 워커 감지가 가능하려면 워커 pane이 먼저 존재해야 함)
|
|
358
|
+
|
|
359
|
+
const dispatches = [];
|
|
360
|
+
for (let i = 0; i < assignments.length; i++) {
|
|
361
|
+
const assignment = assignments[i];
|
|
362
|
+
const paneName = `worker-${i + 1}`;
|
|
363
|
+
const resolvedCli = resolveCliType(assignment.cli);
|
|
364
|
+
const brand = CLI_BRAND[resolvedCli] || { emoji: "\u{25CF}", label: resolvedCli, ansi: "" };
|
|
365
|
+
const paneTitle = assignment.role
|
|
366
|
+
? `${brand.emoji} ${resolvedCli} (${assignment.role})`
|
|
367
|
+
: `${brand.emoji} ${resolvedCli}-${i + 1}`;
|
|
368
|
+
|
|
369
|
+
let newPaneId;
|
|
370
|
+
// 모든 워커를 split-window로 생성 (lead pane index 0은 비워둠)
|
|
371
|
+
// tui-viewer가 index 0을 건너뛰므로, 워커는 항상 index >= 1에 배치
|
|
372
|
+
newPaneId = psmuxExec([
|
|
373
|
+
"split-window", "-t", sessionName, "-P", "-F",
|
|
374
|
+
"#{session_name}:#{window_index}.#{pane_index}",
|
|
375
|
+
]);
|
|
376
|
+
|
|
377
|
+
// 타이틀 설정 (이모지 포함)
|
|
378
|
+
try { psmuxExec(["select-pane", "-t", newPaneId, "-T", paneTitle]); } catch { /* 무시 */ }
|
|
379
|
+
|
|
380
|
+
if (safeProgress) safeProgress({ type: "worker_added", paneName, cli: assignment.cli, paneTitle });
|
|
381
|
+
|
|
382
|
+
// 캡처 시작 + 컬러 배너 + 명령 dispatch
|
|
383
|
+
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
384
|
+
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model });
|
|
385
|
+
startCapture(sessionName, newPaneId);
|
|
386
|
+
// pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
|
|
387
|
+
if (i > 0) await new Promise(r => setTimeout(r, 300));
|
|
388
|
+
const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
|
|
389
|
+
|
|
390
|
+
if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
391
|
+
|
|
392
|
+
dispatches.push({ ...dispatch, paneId: newPaneId, paneName, resultFile, cli: assignment.cli, role: assignment.role, command: cmd });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 모든 split 완료 후 레이아웃 한 번만 정렬 (깜빡임 방지)
|
|
396
|
+
try { psmuxExec(["select-layout", "-t", sessionName, "tiled"]); } catch { /* 무시 */ }
|
|
397
|
+
|
|
398
|
+
// v7.1.3: psmux 내부 대시보드 pane 제거 — WT 스플릿에서 tui-viewer 직접 실행
|
|
399
|
+
|
|
400
|
+
return dispatches;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** 기존 batch 모드: 모든 pane을 한 번에 생성하여 dispatch */
|
|
404
|
+
function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
405
|
+
const {
|
|
406
|
+
layout,
|
|
407
|
+
safeProgress,
|
|
408
|
+
dashboardLayout = "single",
|
|
409
|
+
} = opts;
|
|
410
|
+
const paneCount = assignments.length + 1;
|
|
411
|
+
const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
|
|
412
|
+
// A2b fix: 2x2 레이아웃은 최대 4 pane — 초과 시 tiled로 자동 전환
|
|
413
|
+
const effectiveLayout = (layout === "2x2" && paneCount > 4) ? "tiled" : layout;
|
|
414
|
+
const session = createPsmuxSession(sessionName, { layout: effectiveLayout, paneCount });
|
|
415
|
+
applyTrifluxTheme(sessionName);
|
|
416
|
+
if (safeProgress) {
|
|
417
|
+
safeProgress({
|
|
418
|
+
type: "session_created",
|
|
419
|
+
sessionName,
|
|
420
|
+
panes: session.panes,
|
|
421
|
+
dashboardLayout: resolvedDashboardLayout,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return assignments.map((assignment, i) => {
|
|
426
|
+
const paneName = `worker-${i + 1}`;
|
|
427
|
+
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
428
|
+
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model });
|
|
429
|
+
const scriptDir = join(RESULT_DIR, sessionName);
|
|
430
|
+
const dispatch = dispatchCommand(sessionName, paneName, cmd, { scriptDir, scriptName: paneName });
|
|
431
|
+
|
|
432
|
+
// P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
|
|
433
|
+
// 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
|
|
434
|
+
// progressive 모드에서는 split-window 시 새 pane에 바로 타이틀이 설정되므로 문제없음
|
|
435
|
+
|
|
436
|
+
if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
437
|
+
|
|
438
|
+
return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role, command: cmd };
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* 모든 dispatch를 병렬 대기하며 완료 결과를 수집한다.
|
|
444
|
+
* @param {string} sessionName
|
|
445
|
+
* @param {Array} dispatches
|
|
446
|
+
* @param {number} timeoutSec
|
|
447
|
+
* @param {Function|null} safeProgress
|
|
448
|
+
* @param {number} progressIntervalSec
|
|
449
|
+
* @returns {Promise<Array<{d, completion, output}>>}
|
|
450
|
+
*/
|
|
451
|
+
async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec, stallOpts) {
|
|
452
|
+
// 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
|
|
453
|
+
return Promise.all(dispatches.map(async (d) => {
|
|
454
|
+
// onPoll → onProgress 변환 (throttle by progressIntervalSec)
|
|
455
|
+
const pollOpts = {};
|
|
456
|
+
if (safeProgress && progressIntervalSec > 0) {
|
|
457
|
+
let lastProgressAt = 0;
|
|
458
|
+
const intervalMs = progressIntervalSec * 1000;
|
|
459
|
+
pollOpts.onPoll = ({ content }) => {
|
|
460
|
+
const now = Date.now();
|
|
461
|
+
if (now - lastProgressAt >= intervalMs) {
|
|
462
|
+
lastProgressAt = now;
|
|
463
|
+
safeProgress({
|
|
464
|
+
type: "progress",
|
|
465
|
+
paneName: d.paneName,
|
|
466
|
+
cli: d.cli,
|
|
467
|
+
snapshot: content.split("\n").slice(-15).join("\n"), // 마지막 15줄
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
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);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const output = completion.matched
|
|
529
|
+
? readResult(d.resultFile, d.paneId)
|
|
530
|
+
: "";
|
|
531
|
+
|
|
532
|
+
if (safeProgress) {
|
|
533
|
+
safeProgress({
|
|
534
|
+
type: "completed",
|
|
535
|
+
paneName: d.paneName,
|
|
536
|
+
cli: d.cli,
|
|
537
|
+
matched: completion.matched,
|
|
538
|
+
exitCode: completion.exitCode,
|
|
539
|
+
sessionDead: completion.sessionDead || false,
|
|
540
|
+
stallDetected: completion.stallDetected || false,
|
|
541
|
+
stallExhausted: completion.stallExhausted || false,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return { d, completion, output };
|
|
546
|
+
}));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* git diff + handoff 파이프라인을 적용하여 최종 결과 배열을 반환한다.
|
|
551
|
+
* @param {Array<{d, completion, output}>} results
|
|
552
|
+
* @returns {Array}
|
|
553
|
+
*/
|
|
554
|
+
function collectResults(results) {
|
|
555
|
+
// B3 fix: git diff를 루프 밖에서 1회만 실행 (워커 수만큼 중복 방지)
|
|
556
|
+
let gitDiffFiles;
|
|
557
|
+
try {
|
|
558
|
+
const diffOut = execSync("git diff --name-only HEAD", { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
|
|
559
|
+
gitDiffFiles = diffOut.trim().split("\n").filter(Boolean);
|
|
560
|
+
} catch { /* git 미설치 또는 non-repo — 무시 */ }
|
|
561
|
+
|
|
562
|
+
// handoff 파이프라인: parse → validate → format (각 워커 결과에 적용)
|
|
563
|
+
return results.map(({ d, completion, output }) => {
|
|
564
|
+
const handoffResult = processHandoff(output, {
|
|
565
|
+
exitCode: completion.exitCode,
|
|
566
|
+
resultFile: d.resultFile,
|
|
567
|
+
cli: d.cli,
|
|
568
|
+
gitDiffFiles,
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
cli: d.cli,
|
|
573
|
+
paneName: d.paneName,
|
|
574
|
+
paneId: d.paneId,
|
|
575
|
+
role: d.role,
|
|
576
|
+
matched: completion.matched,
|
|
577
|
+
exitCode: completion.exitCode,
|
|
578
|
+
output,
|
|
579
|
+
resultFile: d.resultFile,
|
|
580
|
+
sessionDead: completion.sessionDead || false,
|
|
581
|
+
handoff: handoffResult.handoff,
|
|
582
|
+
handoffFormatted: handoffResult.formatted,
|
|
583
|
+
handoffValid: handoffResult.valid,
|
|
584
|
+
handoffFallback: handoffResult.fallback,
|
|
585
|
+
};
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 헤드리스 CLI 오케스트레이션 실행
|
|
591
|
+
*
|
|
592
|
+
* @param {string} sessionName — psmux 세션 이름
|
|
593
|
+
* @param {Array<{cli: string, prompt: string, role?: string}>} assignments
|
|
594
|
+
* @param {object} [opts]
|
|
595
|
+
* @param {number} [opts.timeoutSec=300] — 각 워커 타임아웃
|
|
596
|
+
* @param {string} [opts.layout='2x2'] — pane 레이아웃
|
|
597
|
+
* @param {(event: object) => void} [opts.onProgress] — 진행 콜백
|
|
598
|
+
* @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
|
|
599
|
+
* @param {boolean} [opts.progressive=true] — true면 pane을 하나씩 split-window로 추가 (실시간 스플릿)
|
|
600
|
+
* @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
|
|
601
|
+
* @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
|
|
602
|
+
*/
|
|
603
|
+
export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
604
|
+
const {
|
|
605
|
+
timeoutSec = 300,
|
|
606
|
+
layout = "2x2",
|
|
607
|
+
onProgress,
|
|
608
|
+
progressIntervalSec = 0,
|
|
609
|
+
progressive = true,
|
|
610
|
+
dashboard = false,
|
|
611
|
+
dashboardLayout = "single",
|
|
612
|
+
stallDetect,
|
|
613
|
+
} = opts;
|
|
614
|
+
|
|
615
|
+
mkdirSync(RESULT_DIR, { recursive: true });
|
|
616
|
+
|
|
617
|
+
// in-process TUI: dashboard=true이고 stdout이 TTY일 때 직접 구동
|
|
618
|
+
let tui = null;
|
|
619
|
+
const resolvedLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
|
|
620
|
+
if (dashboard && process.stdout.isTTY) {
|
|
621
|
+
tui = createLogDashboard({
|
|
622
|
+
stream: process.stdout,
|
|
623
|
+
input: process.stdin,
|
|
624
|
+
refreshMs: 200,
|
|
625
|
+
layout: resolvedLayout,
|
|
626
|
+
});
|
|
627
|
+
tui.setStartTime(Date.now());
|
|
628
|
+
// 초기 워커 상태 등록
|
|
629
|
+
for (let i = 0; i < assignments.length; i++) {
|
|
630
|
+
const a = assignments[i];
|
|
631
|
+
tui.updateWorker(`worker-${i + 1}`, {
|
|
632
|
+
cli: a.cli || "codex",
|
|
633
|
+
role: a.role || "",
|
|
634
|
+
status: "pending",
|
|
635
|
+
progress: 0,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// per-worker state feed: onProgress 이벤트 → tui.updateWorker()
|
|
641
|
+
function feedTui(event) {
|
|
642
|
+
if (!tui) return;
|
|
643
|
+
const { type, paneName, cli, snapshot, matched, exitCode } = event;
|
|
644
|
+
if (!paneName) return;
|
|
645
|
+
|
|
646
|
+
if (type === "progress" && snapshot) {
|
|
647
|
+
tui.updateWorker(paneName, {
|
|
648
|
+
cli: cli || "codex",
|
|
649
|
+
status: "running",
|
|
650
|
+
snapshot: snapshot.split("\n").at(-1) || "",
|
|
651
|
+
summary: snapshot.split("\n").at(-1) || "",
|
|
652
|
+
detail: snapshot,
|
|
653
|
+
progress: 0.5,
|
|
654
|
+
});
|
|
655
|
+
} else if (type === "completed") {
|
|
656
|
+
const status = matched && exitCode === 0 ? "completed" : "failed";
|
|
657
|
+
tui.updateWorker(paneName, {
|
|
658
|
+
cli: cli || "codex",
|
|
659
|
+
status,
|
|
660
|
+
progress: 1,
|
|
661
|
+
});
|
|
662
|
+
} else if (type === "worker_added") {
|
|
663
|
+
tui.updateWorker(paneName, {
|
|
664
|
+
cli: cli || "codex",
|
|
665
|
+
status: "running",
|
|
666
|
+
progress: 0.05,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
|
|
672
|
+
const combinedProgress = (event) => {
|
|
673
|
+
feedTui(event);
|
|
674
|
+
if (onProgress) { try { onProgress(event); } catch { /* 콜백 예외 삼킴 */ } }
|
|
675
|
+
};
|
|
676
|
+
const safeProgress = (event) => { try { combinedProgress(event); } catch { /* 삼킴 */ } };
|
|
677
|
+
|
|
678
|
+
const dispatches = progressive
|
|
679
|
+
? await dispatchProgressive(sessionName, assignments, { layout, safeProgress, dashboardLayout })
|
|
680
|
+
: dispatchBatch(sessionName, assignments, { layout, safeProgress, dashboardLayout });
|
|
681
|
+
|
|
682
|
+
const results = await awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec, stallDetect);
|
|
683
|
+
const collected = collectResults(results);
|
|
684
|
+
|
|
685
|
+
// 완료 시 TUI에 최종 상태 반영 후 닫기
|
|
686
|
+
if (tui) {
|
|
687
|
+
for (const r of collected) {
|
|
688
|
+
tui.updateWorker(r.paneName, {
|
|
689
|
+
cli: r.cli,
|
|
690
|
+
role: r.role || "",
|
|
691
|
+
status: r.handoff?.status === "failed" ? "failed" : "completed",
|
|
692
|
+
handoff: r.handoff,
|
|
693
|
+
summary: r.handoff?.verdict || (r.matched ? "completed" : "failed"),
|
|
694
|
+
detail: r.output,
|
|
695
|
+
progress: 1,
|
|
696
|
+
elapsed: Math.round((Date.now() - (tui._startedAt || Date.now())) / 1000),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
tui.render();
|
|
700
|
+
// 최종 화면을 잠깐 유지 후 닫기
|
|
701
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
702
|
+
tui.close();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return { sessionName, results: collected };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* 헤드리스 실행 + 자동 정리
|
|
710
|
+
* 성공/실패에 관계없이 세션을 정리한다.
|
|
711
|
+
*
|
|
712
|
+
* @param {Array<{cli: string, prompt: string, role?: string}>} assignments
|
|
713
|
+
* @param {object} [opts] — runHeadless opts + sessionPrefix
|
|
714
|
+
* @returns {{ results: Array, sessionName: string }}
|
|
715
|
+
*/
|
|
716
|
+
export async function runHeadlessWithCleanup(assignments, opts = {}) {
|
|
717
|
+
const { sessionPrefix = "tfx-hl", ...runOpts } = opts;
|
|
718
|
+
const sessionName = `${sessionPrefix}-${Date.now().toString(36).slice(-6)}`;
|
|
719
|
+
|
|
720
|
+
try {
|
|
721
|
+
return await runHeadless(sessionName, assignments, runOpts);
|
|
722
|
+
} finally {
|
|
723
|
+
try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
|
|
724
|
+
// WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
|
|
725
|
+
// 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ─── v6.0.0: Theme + Visual ───
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* psmux 세션에 triflux 테마를 적용한다.
|
|
733
|
+
* status bar + pane border 색상 + 브랜딩.
|
|
734
|
+
*
|
|
735
|
+
* @param {string} sessionName
|
|
736
|
+
*/
|
|
737
|
+
export function applyTrifluxTheme(sessionName) {
|
|
738
|
+
const opts = [
|
|
739
|
+
// Status bar — Catppuccin Mocha 기반
|
|
740
|
+
["status-style", "bg=#1e1e2e,fg=#cdd6f4"],
|
|
741
|
+
["status-left", " #[fg=#89b4fa,bold]▲ triflux#[default] "],
|
|
742
|
+
["status-left-length", "20"],
|
|
743
|
+
["status-right", " #[fg=#a6adc8]#{pane_title}#[default] │ #[fg=#f9e2af]%H:%M#[default] "],
|
|
744
|
+
["status-right-length", "40"],
|
|
745
|
+
// Pane border — active/inactive 구분
|
|
746
|
+
["pane-active-border-style", "fg=#89b4fa"],
|
|
747
|
+
["pane-border-style", "fg=#45475a"],
|
|
748
|
+
// Status bar 위치
|
|
749
|
+
["status-position", "bottom"],
|
|
750
|
+
// 셸이 pane 타이틀을 변경하는 것 방지 (캡처 로그 경로 안정성)
|
|
751
|
+
["allow-rename", "off"],
|
|
752
|
+
];
|
|
753
|
+
for (const [key, value] of opts) {
|
|
754
|
+
try { psmuxExec(["set-option", "-t", sessionName, key, value]); } catch { /* 무시 */ }
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Windows Terminal에 triflux 프로필을 자동 생성/갱신한다.
|
|
760
|
+
* 반투명 + 비포커스 시 더 투명 + Catppuccin 테마.
|
|
761
|
+
* @returns {boolean} 성공 여부
|
|
762
|
+
*/
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* WT 기본 프로필의 폰트 크기를 읽는다.
|
|
766
|
+
* @returns {number} 기본 폰트 크기 (못 읽으면 12)
|
|
767
|
+
*/
|
|
768
|
+
function getWtDefaultFontSize() {
|
|
769
|
+
const settingsPaths = [
|
|
770
|
+
join(process.env.LOCALAPPDATA || "", "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json"),
|
|
771
|
+
join(process.env.LOCALAPPDATA || "", "Microsoft/Windows Terminal/settings.json"),
|
|
772
|
+
];
|
|
773
|
+
for (const p of settingsPaths) {
|
|
774
|
+
if (!existsSync(p)) continue;
|
|
775
|
+
try {
|
|
776
|
+
const settings = JSON.parse(readFileSync(p, "utf8").replace(/^\s*\/\/.*$/gm, ""));
|
|
777
|
+
// 기본 프로필 or 첫 프로필의 폰트
|
|
778
|
+
const defaultGuid = settings.defaultProfile;
|
|
779
|
+
const profiles = settings.profiles?.list || [];
|
|
780
|
+
const defaultProfile = profiles.find(pr => pr.guid === defaultGuid) || profiles[0];
|
|
781
|
+
return defaultProfile?.font?.size || settings.profiles?.defaults?.font?.size || 12;
|
|
782
|
+
} catch { /* 다음 */ }
|
|
783
|
+
}
|
|
784
|
+
return 12;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* 파일을 원자적으로 쓴다 — 임시 파일에 먼저 기록 후 rename으로 교체.
|
|
789
|
+
* 프로세스가 쓰기 도중 충돌해도 원본 파일이 손상되지 않는다.
|
|
790
|
+
* @param {string} filePath — 대상 파일 경로
|
|
791
|
+
* @param {string} data — 쓸 내용
|
|
792
|
+
*/
|
|
793
|
+
function atomicWriteSync(filePath, data) {
|
|
794
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
795
|
+
try {
|
|
796
|
+
writeFileSync(tmpPath, data, "utf8");
|
|
797
|
+
renameSync(tmpPath, filePath);
|
|
798
|
+
} catch (err) {
|
|
799
|
+
try { writeFileSync(tmpPath.replace(/\.tmp$/, ".tmp.del"), ""); } catch { /* 무시 */ }
|
|
800
|
+
throw err;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function sanitizeSessionName(value) {
|
|
805
|
+
return String(value || "").replace(/[^a-zA-Z0-9_\-]/g, "") || "tfx-session";
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function sanitizeWindowTitle(value, fallback = "triflux") {
|
|
809
|
+
const text = String(value || "").replace(/[\r\n]+/g, " ").trim();
|
|
810
|
+
return text || fallback;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function buildWtAttachPaneArgs(sessionName, title) {
|
|
814
|
+
const safeSession = sanitizeSessionName(sessionName);
|
|
815
|
+
return [
|
|
816
|
+
"--profile", "triflux",
|
|
817
|
+
"--title", sanitizeWindowTitle(title, `▲ ${safeSession}`),
|
|
818
|
+
"--", "psmux", "attach-session", "-t", safeSession,
|
|
819
|
+
];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function joinWtCommands(commands) {
|
|
823
|
+
return commands.flatMap((command, index) => (index === 0 ? command : [";", ...command]));
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function buildAttachTitle(sessionName, suffix = "") {
|
|
827
|
+
const base = `▲ ${sanitizeSessionName(sessionName)}`;
|
|
828
|
+
return suffix ? `${base} ${suffix}` : base;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export function buildDashboardAttachArgs(sessionName, dashboardLayout = "single", workerCount = 2, anchor = "window") {
|
|
832
|
+
const safeSession = sanitizeSessionName(sessionName);
|
|
833
|
+
const resolvedLayout = resolveDashboardLayout(dashboardLayout, workerCount);
|
|
834
|
+
const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
|
|
835
|
+
const prefix = anchor === "tab" ? ["-w", "0", "nt"] : ["-w", "new"];
|
|
836
|
+
return [
|
|
837
|
+
...prefix,
|
|
838
|
+
"--profile", "triflux",
|
|
839
|
+
"--title", buildAttachTitle(safeSession, "dashboard"),
|
|
840
|
+
"--", "node", viewerPath,
|
|
841
|
+
"--session", safeSession,
|
|
842
|
+
"--result-dir", RESULT_DIR,
|
|
843
|
+
"--layout", resolvedLayout,
|
|
844
|
+
];
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export function buildWtAttachArgs(sessionName, workerCount = 1) {
|
|
848
|
+
const safeSession = sanitizeSessionName(sessionName);
|
|
849
|
+
const count = Number.isFinite(workerCount) ? Math.max(1, Math.trunc(workerCount)) : 1;
|
|
850
|
+
if (count >= 5) return buildDashboardAttachArgs(safeSession, "single", count);
|
|
851
|
+
|
|
852
|
+
const pane1 = ["nt", ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession))];
|
|
853
|
+
if (count === 1) return ["-w", "0", ...pane1];
|
|
854
|
+
|
|
855
|
+
const pane2 = ["sp", count >= 3 ? "-V" : "-H", ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession, "2"))];
|
|
856
|
+
if (count === 2) return ["-w", "0", ...joinWtCommands([pane1, pane2])];
|
|
857
|
+
|
|
858
|
+
const pane3 = ["sp", "-H", ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession, "3"))];
|
|
859
|
+
if (count === 3) return ["-w", "0", ...joinWtCommands([pane1, pane2, ["move-focus", "left"], pane3])];
|
|
860
|
+
|
|
861
|
+
const pane4 = ["sp", "-H", ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession, "4"))];
|
|
862
|
+
return [
|
|
863
|
+
"-w", "0",
|
|
864
|
+
...joinWtCommands([pane1, pane2, ["move-focus", "left"], pane3, ["move-focus", "right"], pane4]),
|
|
865
|
+
];
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function spawnDetachedWt(args) {
|
|
869
|
+
const child = spawn("wt.exe", args, {
|
|
870
|
+
detached: true,
|
|
871
|
+
stdio: "ignore",
|
|
872
|
+
windowsHide: false,
|
|
873
|
+
});
|
|
874
|
+
child.unref();
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export function ensureWtProfile(workerCount = 2) {
|
|
878
|
+
const settingsPaths = [
|
|
879
|
+
join(process.env.LOCALAPPDATA || "", "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json"),
|
|
880
|
+
join(process.env.LOCALAPPDATA || "", "Microsoft/Windows Terminal/settings.json"),
|
|
881
|
+
];
|
|
882
|
+
|
|
883
|
+
for (const settingsPath of settingsPaths) {
|
|
884
|
+
if (!existsSync(settingsPath)) continue;
|
|
885
|
+
try {
|
|
886
|
+
const raw = readFileSync(settingsPath, "utf8");
|
|
887
|
+
// JSON with comments — 간단한 strip (// 주석만)
|
|
888
|
+
const cleaned = raw.replace(/^\s*\/\/.*$/gm, "");
|
|
889
|
+
const settings = JSON.parse(cleaned);
|
|
890
|
+
if (!settings.profiles?.list) continue;
|
|
891
|
+
|
|
892
|
+
const existing = settings.profiles.list.findIndex(p => p.name === "triflux");
|
|
893
|
+
const profile = {
|
|
894
|
+
name: "triflux",
|
|
895
|
+
commandline: "psmux",
|
|
896
|
+
icon: "\u{1F53A}", // 🔺
|
|
897
|
+
tabTitle: "triflux",
|
|
898
|
+
suppressApplicationTitle: true,
|
|
899
|
+
opacity: 40,
|
|
900
|
+
useAcrylic: true,
|
|
901
|
+
unfocusedAppearance: { opacity: 20 },
|
|
902
|
+
colorScheme: "One Half Dark",
|
|
903
|
+
font: { size: Math.max(6, getWtDefaultFontSize() - 1 - Math.floor(workerCount / 2)) },
|
|
904
|
+
closeOnExit: "always",
|
|
905
|
+
hidden: true, // 프로필 목록에는 숨김 (triflux에서만 사용)
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
if (existing >= 0) {
|
|
909
|
+
settings.profiles.list[existing] = { ...settings.profiles.list[existing], ...profile };
|
|
910
|
+
} else {
|
|
911
|
+
settings.profiles.list.push(profile);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
atomicWriteSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
915
|
+
return true;
|
|
916
|
+
} catch { /* 파싱 실패 — 다음 경로 */ }
|
|
917
|
+
}
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ─── v6.0.0: Lead-Direct Interactive Mode ───
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Windows Terminal에서 psmux 세션을 자동 attach한다.
|
|
925
|
+
* 1명은 새 탭 단일 attach, 2/3/4명은 새 탭 내 split-pane, 5명 이상은 dashboard로 전환한다.
|
|
926
|
+
*
|
|
927
|
+
* @param {string} sessionName — attach할 psmux 세션 이름
|
|
928
|
+
* @param {object} [opts]
|
|
929
|
+
* @param {number} [workerCount=2]
|
|
930
|
+
* @returns {boolean} 성공 여부
|
|
931
|
+
*/
|
|
932
|
+
export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
|
|
933
|
+
if (!process.env.WT_SESSION) return false;
|
|
934
|
+
try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
|
|
935
|
+
ensureWtProfile(workerCount);
|
|
936
|
+
try {
|
|
937
|
+
const args = workerCount >= 5
|
|
938
|
+
? buildDashboardAttachArgs(sessionName, opts.dashboardLayout, workerCount, opts.dashboardAnchor)
|
|
939
|
+
: buildWtAttachArgs(sessionName, workerCount);
|
|
940
|
+
spawnDetachedWt(args);
|
|
941
|
+
return true;
|
|
942
|
+
} catch { return false; }
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* v7.0: psmux 세션을 WT 탭에 attach (대시보드 + 워커 전체 뷰)
|
|
947
|
+
* @param {string} sessionName
|
|
948
|
+
* @param {number} workerCount
|
|
949
|
+
* @param {string} [dashboardLayout='single']
|
|
950
|
+
* @param {number} [dashboardSize=0.50] — 하위 호환용 인자 (현재는 anchor 기반 attach만 사용)
|
|
951
|
+
* @param {string} [dashboardAnchor='window'] — window | tab
|
|
952
|
+
* @returns {boolean}
|
|
953
|
+
*/
|
|
954
|
+
export function attachDashboardTab(sessionName, workerCount = 2, dashboardLayout = "single", dashboardSize = 0.40, dashboardAnchor = "window") {
|
|
955
|
+
try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
|
|
956
|
+
ensureWtProfile(workerCount);
|
|
957
|
+
try {
|
|
958
|
+
const args = buildDashboardAttachArgs(sessionName, dashboardLayout, workerCount, dashboardAnchor);
|
|
959
|
+
void dashboardSize;
|
|
960
|
+
spawnDetachedWt(args);
|
|
961
|
+
return true;
|
|
962
|
+
} catch { return false; }
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* 모든 워커 pane의 현재 스냅샷을 수집한다.
|
|
967
|
+
*
|
|
968
|
+
* @param {string} sessionName
|
|
969
|
+
* @param {Array<{paneId: string, paneName: string, cli: string}>} dispatches
|
|
970
|
+
* @param {number} [lines=15] — 각 pane에서 캡처할 줄 수
|
|
971
|
+
* @returns {Array<{paneName: string, cli: string, snapshot: string}>}
|
|
972
|
+
*/
|
|
973
|
+
export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
|
|
974
|
+
if (!psmuxSessionExists(sessionName)) return [];
|
|
975
|
+
return dispatches.map((d) => {
|
|
976
|
+
try {
|
|
977
|
+
const snapshot = capturePsmuxPane(d.paneId, lines);
|
|
978
|
+
return { paneName: d.paneName, cli: d.cli, snapshot };
|
|
979
|
+
} catch {
|
|
980
|
+
return { paneName: d.paneName, cli: d.cli, snapshot: "(캡처 실패)" };
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Lead-Direct Interactive 헤드리스 실행.
|
|
987
|
+
* 세션을 유지하면서 결과 수집 후에도 추가 명령을 dispatch할 수 있다.
|
|
988
|
+
* 반환된 handle의 kill()을 반드시 호출하여 세션을 정리해야 한다.
|
|
989
|
+
*
|
|
990
|
+
* @param {string} sessionName — psmux 세션 이름
|
|
991
|
+
* @param {Array<{cli: string, prompt: string, role?: string}>} assignments
|
|
992
|
+
* @param {object} [opts]
|
|
993
|
+
* @param {number} [opts.timeoutSec=300]
|
|
994
|
+
* @param {string} [opts.layout='2x2']
|
|
995
|
+
* @param {(event: object) => void} [opts.onProgress]
|
|
996
|
+
* @param {number} [opts.progressIntervalSec=0]
|
|
997
|
+
* @param {boolean} [opts.autoAttach=false] — Windows Terminal 자동 attach
|
|
998
|
+
* @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
|
|
999
|
+
* @param {AbortSignal} [opts.signal] — abort 시 자동 세션 정리
|
|
1000
|
+
* @param {number} [opts.maxIdleSec=0] — 유휴 시 자동 정리 (0=비활성)
|
|
1001
|
+
* @returns {Promise<{
|
|
1002
|
+
* sessionName: string,
|
|
1003
|
+
* results: Array,
|
|
1004
|
+
* dispatches: Array,
|
|
1005
|
+
* dispatch: (paneName: string, command: string) => {paneId: string, paneName: string, token: string},
|
|
1006
|
+
* capture: (paneName: string, lines?: number) => string,
|
|
1007
|
+
* snapshots: (lines?: number) => Array,
|
|
1008
|
+
* waitFor: (paneName: string, token: string, timeoutSec?: number, opts?: object) => Promise,
|
|
1009
|
+
* alive: () => boolean,
|
|
1010
|
+
* kill: () => void,
|
|
1011
|
+
* }>}
|
|
1012
|
+
*/
|
|
1013
|
+
export async function runHeadlessInteractive(sessionName, assignments, opts = {}) {
|
|
1014
|
+
const {
|
|
1015
|
+
autoAttach = false,
|
|
1016
|
+
dashboard = false,
|
|
1017
|
+
dashboardSize = 0.40,
|
|
1018
|
+
dashboardAnchor = "window",
|
|
1019
|
+
signal,
|
|
1020
|
+
maxIdleSec = 0,
|
|
1021
|
+
...runOpts
|
|
1022
|
+
} = opts;
|
|
1023
|
+
const headlessOpts = dashboard
|
|
1024
|
+
? { ...runOpts, dashboard: true }
|
|
1025
|
+
: { ...runOpts };
|
|
1026
|
+
|
|
1027
|
+
// autoAttach를 session_created 시점에 트리거 (CLI 실행 전에 터미널 열림)
|
|
1028
|
+
const userOnProgress = headlessOpts.onProgress;
|
|
1029
|
+
let terminalAttached = false;
|
|
1030
|
+
const onProgress = (event) => {
|
|
1031
|
+
if (autoAttach && event.type === "session_created" && !terminalAttached) {
|
|
1032
|
+
terminalAttached = true;
|
|
1033
|
+
if (dashboard) {
|
|
1034
|
+
// v7.0: psmux attach로 대시보드+워커 전체 세션을 WT 탭에 표시
|
|
1035
|
+
attachDashboardTab(
|
|
1036
|
+
sessionName,
|
|
1037
|
+
assignments.length,
|
|
1038
|
+
event.dashboardLayout || resolveDashboardLayout(headlessOpts.dashboardLayout, assignments.length),
|
|
1039
|
+
dashboardSize,
|
|
1040
|
+
dashboardAnchor,
|
|
1041
|
+
);
|
|
1042
|
+
} else {
|
|
1043
|
+
autoAttachTerminal(sessionName, { dashboardLayout: headlessOpts.dashboardLayout, dashboardAnchor }, assignments.length);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
if (userOnProgress) userOnProgress(event);
|
|
1047
|
+
};
|
|
1048
|
+
const interactiveRunOpts = { ...headlessOpts, onProgress };
|
|
1049
|
+
|
|
1050
|
+
// Phase 1: 세션 생성 → 즉시 터미널 팝업 → dispatch → 대기 → 결과 수집
|
|
1051
|
+
const { results } = await runHeadless(sessionName, assignments, interactiveRunOpts);
|
|
1052
|
+
|
|
1053
|
+
// Phase 2: 세션을 유지하고 interactive handle 반환
|
|
1054
|
+
// Fix P2: paneId를 dispatches에 포함 (snapshots에서 필요)
|
|
1055
|
+
const dispatches = results.map((r, i) => ({
|
|
1056
|
+
paneName: r.paneName,
|
|
1057
|
+
paneId: r.paneId || "",
|
|
1058
|
+
cli: r.cli,
|
|
1059
|
+
role: r.role,
|
|
1060
|
+
}));
|
|
1061
|
+
|
|
1062
|
+
// Fix P2: maxIdleSec 리셋을 위한 타이머 관리
|
|
1063
|
+
let idleTimer = null;
|
|
1064
|
+
function resetIdleTimer() {
|
|
1065
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1066
|
+
if (maxIdleSec > 0) {
|
|
1067
|
+
idleTimer = setTimeout(() => handle.kill(), maxIdleSec * 1000);
|
|
1068
|
+
if (idleTimer.unref) idleTimer.unref();
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const handle = {
|
|
1073
|
+
sessionName,
|
|
1074
|
+
results,
|
|
1075
|
+
dispatches,
|
|
1076
|
+
_killed: false,
|
|
1077
|
+
|
|
1078
|
+
/** 특정 pane에 후속 명령 dispatch (캡처 자동 재시작) */
|
|
1079
|
+
dispatch(paneName, command) {
|
|
1080
|
+
if (this._killed) throw new Error("세션이 이미 종료되었습니다.");
|
|
1081
|
+
try { startCapture(sessionName, paneName); } catch { /* 이미 활성 — 무시 */ }
|
|
1082
|
+
resetIdleTimer();
|
|
1083
|
+
return dispatchCommand(sessionName, paneName, command);
|
|
1084
|
+
},
|
|
1085
|
+
|
|
1086
|
+
/** 특정 pane의 현재 출력 캡처 */
|
|
1087
|
+
capture(paneName, lines = 30) {
|
|
1088
|
+
if (this._killed) return "";
|
|
1089
|
+
try {
|
|
1090
|
+
// Fix P2: paneName으로 resolvePane을 경유하여 정확한 paneId 획득
|
|
1091
|
+
return capturePsmuxPane(paneName, lines);
|
|
1092
|
+
} catch {
|
|
1093
|
+
return "(캡처 실패)";
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
|
|
1097
|
+
/** 모든 pane 스냅샷 */
|
|
1098
|
+
snapshots(lines = 15) {
|
|
1099
|
+
if (this._killed) return [];
|
|
1100
|
+
return getProgressSnapshots(sessionName, dispatches, lines);
|
|
1101
|
+
},
|
|
1102
|
+
|
|
1103
|
+
/** 특정 pane에서 완료 대기 */
|
|
1104
|
+
async waitFor(paneName, token, timeoutSec = 300, waitOpts = {}) {
|
|
1105
|
+
if (this._killed) return { matched: false, sessionDead: true };
|
|
1106
|
+
resetIdleTimer();
|
|
1107
|
+
return waitForCompletion(sessionName, paneName, token, timeoutSec, waitOpts);
|
|
1108
|
+
},
|
|
1109
|
+
|
|
1110
|
+
/** 세션 생존 확인 */
|
|
1111
|
+
alive() {
|
|
1112
|
+
if (this._killed) return false;
|
|
1113
|
+
return psmuxSessionExists(sessionName);
|
|
1114
|
+
},
|
|
1115
|
+
|
|
1116
|
+
/** 세션 종료 — WT pane은 psmux 종료 시 자동으로 닫힘 */
|
|
1117
|
+
kill() {
|
|
1118
|
+
if (this._killed) return;
|
|
1119
|
+
this._killed = true;
|
|
1120
|
+
try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
|
|
1121
|
+
// WT split pane은 psmux 종료 → 셸 종료 → 자동 닫힘
|
|
1122
|
+
// 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)
|
|
1123
|
+
},
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
// AbortController signal로 자동 정리
|
|
1127
|
+
if (signal) {
|
|
1128
|
+
if (signal.aborted) {
|
|
1129
|
+
handle.kill();
|
|
1130
|
+
} else {
|
|
1131
|
+
signal.addEventListener("abort", () => handle.kill(), { once: true });
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// 유휴 타임아웃 자동 정리 (resetIdleTimer 단일 경로 사용)
|
|
1136
|
+
resetIdleTimer();
|
|
1137
|
+
|
|
1138
|
+
// 프로세스 종료 시 safety net
|
|
1139
|
+
const exitHandler = () => handle.kill();
|
|
1140
|
+
process.on("exit", exitHandler);
|
|
1141
|
+
// kill() 후 리스너 제거를 위해 참조 보관
|
|
1142
|
+
const originalKill = handle.kill.bind(handle);
|
|
1143
|
+
handle.kill = function () {
|
|
1144
|
+
originalKill();
|
|
1145
|
+
process.removeListener("exit", exitHandler);
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
return handle;
|
|
1149
|
+
}
|