triflux 3.2.0-dev.9 → 3.3.0-dev.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 +1383 -1383
- package/hooks/hooks.json +17 -0
- package/hooks/keyword-rules.json +4 -4
- package/hooks/pipeline-stop.mjs +54 -0
- package/hub/bridge.mjs +120 -13
- package/hub/pipeline/index.mjs +121 -0
- package/hub/pipeline/state.mjs +164 -0
- package/hub/pipeline/transitions.mjs +114 -0
- package/hub/schema.sql +14 -0
- package/hub/server.mjs +78 -8
- package/hub/team/cli-team-control.mjs +381 -381
- package/hub/team/cli-team-start.mjs +474 -470
- package/hub/team/cli-team-status.mjs +238 -238
- package/hub/team/cli.mjs +86 -86
- package/hub/team/native.mjs +190 -62
- package/hub/team/nativeProxy.mjs +51 -38
- package/hub/team/orchestrator.mjs +15 -20
- package/hub/team/pane.mjs +101 -90
- package/hub/team/psmux.mjs +223 -14
- package/hub/team/session.mjs +450 -450
- package/hub/tools.mjs +126 -41
- package/hud/hud-qos-status.mjs +1790 -1790
- package/package.json +1 -1
- package/scripts/__tests__/keyword-detector.test.mjs +8 -8
- package/scripts/preflight-cache.mjs +72 -0
- package/scripts/setup.mjs +8 -1
- package/scripts/tfx-route.sh +74 -15
- package/skills/{tfx-team → tfx-multi}/SKILL.md +115 -32
package/hub/team/pane.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// hub/team/pane.mjs — pane별 CLI 실행 + stdin 주입
|
|
2
|
-
// 의존성: child_process, fs, os, path (Node.js 내장)만 사용
|
|
1
|
+
// hub/team/pane.mjs — pane별 CLI 실행 + stdin 주입
|
|
2
|
+
// 의존성: child_process, fs, os, path (Node.js 내장)만 사용
|
|
3
3
|
import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
@@ -16,29 +16,29 @@ function getPsmuxSessionName(target) {
|
|
|
16
16
|
|
|
17
17
|
/** Windows 경로를 멀티플렉서용 경로로 변환 */
|
|
18
18
|
function toMuxPath(p) {
|
|
19
|
-
if (process.platform !== "win32") return p;
|
|
20
|
-
|
|
21
|
-
const mux = detectMultiplexer();
|
|
22
|
-
|
|
23
|
-
// psmux는 Windows 네이티브 경로 그대로 사용
|
|
24
|
-
if (mux === "psmux") return p;
|
|
25
|
-
|
|
26
|
-
const normalized = p.replace(/\\/g, "/");
|
|
27
|
-
const m = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
|
28
|
-
if (!m) return normalized;
|
|
29
|
-
|
|
30
|
-
const drive = m[1].toLowerCase();
|
|
31
|
-
const rest = m[2];
|
|
32
|
-
|
|
33
|
-
// wsl tmux는 /mnt/c/... 경로를 사용
|
|
34
|
-
if (mux === "wsl-tmux") {
|
|
35
|
-
return `/mnt/${drive}/${rest}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Git Bash/MSYS tmux는 /c/... 경로를 사용
|
|
39
|
-
return `/${drive}/${rest}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
19
|
+
if (process.platform !== "win32") return p;
|
|
20
|
+
|
|
21
|
+
const mux = detectMultiplexer();
|
|
22
|
+
|
|
23
|
+
// psmux는 Windows 네이티브 경로 그대로 사용
|
|
24
|
+
if (mux === "psmux") return p;
|
|
25
|
+
|
|
26
|
+
const normalized = p.replace(/\\/g, "/");
|
|
27
|
+
const m = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
|
28
|
+
if (!m) return normalized;
|
|
29
|
+
|
|
30
|
+
const drive = m[1].toLowerCase();
|
|
31
|
+
const rest = m[2];
|
|
32
|
+
|
|
33
|
+
// wsl tmux는 /mnt/c/... 경로를 사용
|
|
34
|
+
if (mux === "wsl-tmux") {
|
|
35
|
+
return `/mnt/${drive}/${rest}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Git Bash/MSYS tmux는 /c/... 경로를 사용
|
|
39
|
+
return `/${drive}/${rest}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
42
|
/** 멀티플렉서 커맨드 실행 (session.mjs와 동일 패턴) */
|
|
43
43
|
function muxExec(args, opts = {}) {
|
|
44
44
|
const exec = detectMultiplexer() === "psmux" ? psmuxExec : tmuxExec;
|
|
@@ -46,65 +46,81 @@ function muxExec(args, opts = {}) {
|
|
|
46
46
|
encoding: "utf8",
|
|
47
47
|
timeout: 10000,
|
|
48
48
|
stdio: ["pipe", "pipe", "pipe"],
|
|
49
|
-
...opts,
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* CLI 에이전트 시작 커맨드 생성
|
|
55
|
-
* @param {'codex'|'gemini'|'claude'} cli
|
|
56
|
-
* @param {{ trustMode?: boolean }} [options]
|
|
57
|
-
* @returns {string} 실행할 셸 커맨드
|
|
58
|
-
*/
|
|
59
|
-
export function buildCliCommand(cli, options = {}) {
|
|
60
|
-
const { trustMode = false } = options;
|
|
61
|
-
|
|
62
|
-
switch (cli) {
|
|
63
|
-
case "codex":
|
|
64
|
-
// trust 모드에서는 승인/샌드박스 우회 + alt-screen 비활성화
|
|
65
|
-
return trustMode
|
|
66
|
-
? "codex --dangerously-bypass-approvals-and-sandbox --no-alt-screen"
|
|
67
|
-
: "codex";
|
|
68
|
-
case "gemini":
|
|
69
|
-
// interactive 모드 — MCP는 ~/.gemini/settings.json에 사전 등록
|
|
70
|
-
return "gemini";
|
|
71
|
-
case "claude":
|
|
72
|
-
// interactive 모드
|
|
73
|
-
return "claude";
|
|
74
|
-
default:
|
|
75
|
-
return cli; // 커스텀 CLI 허용
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
49
|
+
...opts,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* CLI 에이전트 시작 커맨드 생성
|
|
55
|
+
* @param {'codex'|'gemini'|'claude'} cli
|
|
56
|
+
* @param {{ trustMode?: boolean }} [options]
|
|
57
|
+
* @returns {string} 실행할 셸 커맨드
|
|
58
|
+
*/
|
|
59
|
+
export function buildCliCommand(cli, options = {}) {
|
|
60
|
+
const { trustMode = false } = options;
|
|
61
|
+
|
|
62
|
+
switch (cli) {
|
|
63
|
+
case "codex":
|
|
64
|
+
// trust 모드에서는 승인/샌드박스 우회 + alt-screen 비활성화
|
|
65
|
+
return trustMode
|
|
66
|
+
? "codex --dangerously-bypass-approvals-and-sandbox --no-alt-screen"
|
|
67
|
+
: "codex";
|
|
68
|
+
case "gemini":
|
|
69
|
+
// interactive 모드 — MCP는 ~/.gemini/settings.json에 사전 등록
|
|
70
|
+
return "gemini";
|
|
71
|
+
case "claude":
|
|
72
|
+
// interactive 모드
|
|
73
|
+
return "claude";
|
|
74
|
+
default:
|
|
75
|
+
return cli; // 커스텀 CLI 허용
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
80
|
* pane에 CLI 시작
|
|
81
|
-
* @param {string} target — 예: tfx-
|
|
82
|
-
* @param {string} command — 실행할 커맨드
|
|
83
|
-
*/
|
|
81
|
+
* @param {string} target — 예: tfx-multi-abc:0.1
|
|
82
|
+
* @param {string} command — 실행할 커맨드
|
|
83
|
+
*/
|
|
84
84
|
export function startCliInPane(target, command) {
|
|
85
85
|
// CLI 시작도 buffer paste를 재사용해 셸/플랫폼별 quoting 차이를 제거한다.
|
|
86
86
|
injectPrompt(target, command);
|
|
87
87
|
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* pane에 프롬프트 주입 (load-buffer + paste-buffer 방식)
|
|
91
|
-
* 멀티라인 + 특수문자 안전, 크기 제한 없음
|
|
92
|
-
* @param {string} target — 예: tfx-
|
|
93
|
-
* @param {string} prompt — 주입할 텍스트
|
|
94
|
-
*/
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* pane에 프롬프트 주입 (load-buffer + paste-buffer 방식)
|
|
91
|
+
* 멀티라인 + 특수문자 안전, 크기 제한 없음
|
|
92
|
+
* @param {string} target — 예: tfx-multi-abc:0.1
|
|
93
|
+
* @param {string} prompt — 주입할 텍스트
|
|
94
|
+
*/
|
|
95
|
+
/**
|
|
96
|
+
* pane에 프롬프트 주입
|
|
97
|
+
* @param {string} target — 예: tfx-multi-abc:0.1
|
|
98
|
+
* @param {string} prompt — 주입할 텍스트
|
|
99
|
+
* @param {object} [opts]
|
|
100
|
+
* @param {boolean} [opts.useFileRef] — true면 TUI용 @file 참조 방식 (psmux 전용)
|
|
101
|
+
*/
|
|
102
|
+
export function injectPrompt(target, prompt, { useFileRef = false } = {}) {
|
|
103
|
+
const tmpDir = join(tmpdir(), "tfx-multi");
|
|
104
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
105
|
+
|
|
106
|
+
const safeTarget = target.replace(/[:.]/g, "-");
|
|
107
|
+
const tmpFile = join(tmpDir, `prompt-${safeTarget}-${Date.now()}.txt`);
|
|
108
|
+
|
|
109
|
+
// psmux + TUI 앱: @file 참조로 주입 (paste-buffer는 TUI와 호환 안 됨)
|
|
110
|
+
if (detectMultiplexer() === "psmux" && useFileRef) {
|
|
111
|
+
writeFileSync(tmpFile, prompt, "utf8");
|
|
112
|
+
const filePath = tmpFile.replace(/\\/g, "/");
|
|
113
|
+
psmuxExec(["select-pane", "-t", target]);
|
|
114
|
+
psmuxExec(["send-keys", "-t", target, "-l", `@${filePath}`]);
|
|
115
|
+
psmuxExec(["send-keys", "-t", target, "Enter"]);
|
|
116
|
+
// TUI가 파일을 읽을 시간을 주고 정리
|
|
117
|
+
setTimeout(() => { try { unlinkSync(tmpFile); } catch {} }, 10000);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
104
121
|
try {
|
|
105
122
|
writeFileSync(tmpFile, prompt, "utf8");
|
|
106
123
|
|
|
107
|
-
// psmux는 buffer 명령에 세션 컨텍스트가 필요하다.
|
|
108
124
|
if (detectMultiplexer() === "psmux") {
|
|
109
125
|
const sessionName = getPsmuxSessionName(target);
|
|
110
126
|
psmuxExec(["load-buffer", "-t", sessionName, toMuxPath(tmpFile)]);
|
|
@@ -119,20 +135,15 @@ export function injectPrompt(target, prompt) {
|
|
|
119
135
|
muxExec(`paste-buffer -t ${target}`);
|
|
120
136
|
muxExec(`send-keys -t ${target} Enter`);
|
|
121
137
|
} finally {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* pane에 키 입력 전송
|
|
133
|
-
* @param {string} target — 예: tfx-team-abc:0.1
|
|
134
|
-
* @param {string} keys — tmux 키 표현 (예: 'C-c', 'Enter')
|
|
135
|
-
*/
|
|
138
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* pane에 키 입력 전송
|
|
144
|
+
* @param {string} target — 예: tfx-multi-abc:0.1
|
|
145
|
+
* @param {string} keys — tmux 키 표현 (예: 'C-c', 'Enter')
|
|
146
|
+
*/
|
|
136
147
|
export function sendKeys(target, keys) {
|
|
137
148
|
muxExec(`send-keys -t ${target} ${keys}`);
|
|
138
149
|
}
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { execSync, spawnSync } from "node:child_process";
|
|
4
4
|
|
|
5
5
|
const PSMUX_BIN = process.env.PSMUX_BIN || "psmux";
|
|
6
|
+
const GIT_BASH = process.env.GIT_BASH_PATH || "C:\\Program Files\\Git\\bin\\bash.exe";
|
|
7
|
+
const IS_WINDOWS = process.platform === "win32";
|
|
6
8
|
|
|
7
9
|
function quoteArg(value) {
|
|
8
10
|
const str = String(value);
|
|
@@ -199,14 +201,14 @@ export function psmuxSessionExists(sessionName) {
|
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
/**
|
|
202
|
-
* tfx-
|
|
204
|
+
* tfx-multi- 접두사 psmux 세션 목록
|
|
203
205
|
* @returns {string[]}
|
|
204
206
|
*/
|
|
205
207
|
export function listPsmuxSessions() {
|
|
206
208
|
try {
|
|
207
209
|
return parseSessionSummaries(psmuxExec("list-sessions"))
|
|
208
210
|
.map((session) => session.sessionName)
|
|
209
|
-
.filter((sessionName) => sessionName.startsWith("tfx-
|
|
211
|
+
.filter((sessionName) => sessionName.startsWith("tfx-multi-"));
|
|
210
212
|
} catch {
|
|
211
213
|
return [];
|
|
212
214
|
}
|
|
@@ -268,6 +270,7 @@ export function getPsmuxSessionAttachedCount(sessionName) {
|
|
|
268
270
|
export function configurePsmuxKeybindings(sessionName, opts = {}) {
|
|
269
271
|
const { inProcess = false, taskListCommand = "" } = opts;
|
|
270
272
|
const cond = `#{==:#{session_name},${sessionName}}`;
|
|
273
|
+
const target = `${sessionName}:0`;
|
|
271
274
|
const bindNext = inProcess
|
|
272
275
|
? `'select-pane -t :.+ \\; resize-pane -Z'`
|
|
273
276
|
: `'select-pane -t :.+'`;
|
|
@@ -275,23 +278,229 @@ export function configurePsmuxKeybindings(sessionName, opts = {}) {
|
|
|
275
278
|
? `'select-pane -t :.- \\; resize-pane -Z'`
|
|
276
279
|
: `'select-pane -t :.-'`;
|
|
277
280
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
281
|
+
// psmux는 세션별 서버이므로 -t target으로 세션 컨텍스트를 전달해야 한다
|
|
282
|
+
const bindSafe = (cmd) => {
|
|
283
|
+
try { psmuxExec(`-t ${quoteArg(target)} ${cmd}`); } catch { /* 미지원 시 무시 */ }
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
bindSafe(`bind-key -T root -n S-Down if-shell -F '${cond}' ${bindNext} 'send-keys S-Down'`);
|
|
287
|
+
bindSafe(`bind-key -T root -n S-Up if-shell -F '${cond}' ${bindPrev} 'send-keys S-Up'`);
|
|
288
|
+
bindSafe(`bind-key -T root -n S-Right if-shell -F '${cond}' ${bindNext} 'send-keys S-Right'`);
|
|
289
|
+
bindSafe(`bind-key -T root -n S-Left if-shell -F '${cond}' ${bindPrev} 'send-keys S-Left'`);
|
|
290
|
+
bindSafe(`bind-key -T root -n BTab if-shell -F '${cond}' ${bindPrev} 'send-keys BTab'`);
|
|
291
|
+
bindSafe(`bind-key -T root -n Escape if-shell -F '${cond}' 'send-keys C-c' 'send-keys Escape'`);
|
|
284
292
|
|
|
285
293
|
if (taskListCommand) {
|
|
286
294
|
const escaped = taskListCommand.replace(/'/g, "'\\''");
|
|
295
|
+
bindSafe(
|
|
296
|
+
`bind-key -T root -n C-t if-shell -F '${cond}' "display-popup -E '${escaped}'" "send-keys C-t"`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ─── 하이브리드 모드 워커 관리 함수 ───
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* psmux 세션의 새 pane에서 워커 실행
|
|
305
|
+
* @param {string} sessionName - 대상 psmux 세션 이름
|
|
306
|
+
* @param {string} workerName - 워커 식별용 pane 타이틀
|
|
307
|
+
* @param {string} cmd - 실행할 커맨드
|
|
308
|
+
* @returns {{ paneId: string, workerName: string }}
|
|
309
|
+
*/
|
|
310
|
+
export function spawnWorker(sessionName, workerName, cmd) {
|
|
311
|
+
if (!hasPsmux()) {
|
|
312
|
+
throw new Error("psmux가 설치되어 있지 않습니다. psmux를 먼저 설치하세요.");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// remain-on-exit: 종료된 pane이 즉시 사라지는 것 방지
|
|
316
|
+
try {
|
|
317
|
+
psmuxExec(`set-option -t ${quoteArg(sessionName)} remain-on-exit on`);
|
|
318
|
+
} catch { /* 미지원 시 무시 */ }
|
|
319
|
+
|
|
320
|
+
// Windows: pane 기본셸이 PowerShell → Git Bash로 래핑
|
|
321
|
+
// psmux가 이스케이프 시퀀스를 처리하므로 포워드 슬래시로 변환
|
|
322
|
+
const shellCmd = IS_WINDOWS
|
|
323
|
+
? `& '${GIT_BASH.replace(/\\/g, '/')}' -l -c '${cmd.replace(/'/g, "'\\''")}'`
|
|
324
|
+
: cmd;
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
// 배열 형태 spawnSync → 쉘 해석 우회 (백슬래시 경로 보존)
|
|
328
|
+
const paneTarget = psmux([
|
|
329
|
+
"split-window", "-t", sessionName,
|
|
330
|
+
"-P", "-F", "#{session_name}:#{window_index}.#{pane_index}",
|
|
331
|
+
shellCmd,
|
|
332
|
+
]);
|
|
333
|
+
psmux(["select-pane", "-t", paneTarget, "-T", workerName]);
|
|
334
|
+
return { paneId: paneTarget, workerName };
|
|
335
|
+
} catch (err) {
|
|
336
|
+
throw new Error(`워커 생성 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 워커 pane 실행 상태 확인
|
|
342
|
+
* @param {string} sessionName - 대상 psmux 세션 이름
|
|
343
|
+
* @param {string} workerName - 워커 pane 타이틀
|
|
344
|
+
* @returns {{ status: "running"|"exited", exitCode: number|null, paneId: string }}
|
|
345
|
+
*/
|
|
346
|
+
export function getWorkerStatus(sessionName, workerName) {
|
|
347
|
+
if (!hasPsmux()) {
|
|
348
|
+
throw new Error("psmux가 설치되어 있지 않습니다.");
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
const output = psmuxExec(
|
|
352
|
+
`list-panes -t ${quoteArg(sessionName)} -F "#{pane_title}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_dead}\t#{pane_dead_status}"`
|
|
353
|
+
);
|
|
354
|
+
const lines = output.split("\n").filter(Boolean);
|
|
355
|
+
for (const line of lines) {
|
|
356
|
+
const [title, paneId, dead, deadStatus] = line.split("\t");
|
|
357
|
+
if (title === workerName) {
|
|
358
|
+
const isDead = dead === "1";
|
|
359
|
+
return {
|
|
360
|
+
status: isDead ? "exited" : "running",
|
|
361
|
+
exitCode: isDead ? parseInt(deadStatus, 10) || 0 : null,
|
|
362
|
+
paneId,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
throw new Error(`워커를 찾을 수 없습니다: ${workerName}`);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (err.message.includes("워커를 찾을 수 없습니다")) throw err;
|
|
369
|
+
throw new Error(`워커 상태 조회 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 워커 pane 프로세스 강제 종료
|
|
375
|
+
* @param {string} sessionName - 대상 psmux 세션 이름
|
|
376
|
+
* @param {string} workerName - 워커 pane 타이틀
|
|
377
|
+
* @returns {{ killed: boolean }}
|
|
378
|
+
*/
|
|
379
|
+
export function killWorker(sessionName, workerName) {
|
|
380
|
+
if (!hasPsmux()) {
|
|
381
|
+
throw new Error("psmux가 설치되어 있지 않습니다.");
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
const { paneId, status } = getWorkerStatus(sessionName, workerName);
|
|
385
|
+
|
|
386
|
+
// 이미 종료된 워커 → pane 정리만 수행
|
|
387
|
+
if (status === "exited") {
|
|
388
|
+
try { psmuxExec(`kill-pane -t ${quoteArg(paneId)}`); } catch { /* 무시 */ }
|
|
389
|
+
return { killed: true };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// running → C-c 우아한 종료 시도
|
|
287
393
|
try {
|
|
288
|
-
psmuxExec(
|
|
289
|
-
`bind-key -T root -n C-t if-shell -F '${cond}' "display-popup -E '${escaped}'" "send-keys C-t"`
|
|
290
|
-
);
|
|
394
|
+
psmuxExec(`send-keys -t ${quoteArg(paneId)} C-c`);
|
|
291
395
|
} catch {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
396
|
+
// send-keys 실패 무시
|
|
397
|
+
}
|
|
398
|
+
// 1초 대기 후 pane 강제 종료
|
|
399
|
+
spawnSync("sleep", ["1"], { stdio: "ignore", windowsHide: true });
|
|
400
|
+
try {
|
|
401
|
+
psmuxExec(`kill-pane -t ${quoteArg(paneId)}`);
|
|
402
|
+
} catch {
|
|
403
|
+
// 이미 종료된 pane — 무시
|
|
404
|
+
}
|
|
405
|
+
return { killed: true };
|
|
406
|
+
} catch (err) {
|
|
407
|
+
// 워커를 찾을 수 없음 → 이미 종료된 것으로 간주
|
|
408
|
+
if (err.message.includes("워커를 찾을 수 없습니다")) {
|
|
409
|
+
return { killed: true };
|
|
410
|
+
}
|
|
411
|
+
throw new Error(`워커 종료 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 워커 pane 출력 마지막 N줄 캡처
|
|
417
|
+
* @param {string} sessionName - 대상 psmux 세션 이름
|
|
418
|
+
* @param {string} workerName - 워커 pane 타이틀
|
|
419
|
+
* @param {number} lines - 캡처할 줄 수 (기본 50)
|
|
420
|
+
* @returns {string} 캡처된 출력
|
|
421
|
+
*/
|
|
422
|
+
export function captureWorkerOutput(sessionName, workerName, lines = 50) {
|
|
423
|
+
if (!hasPsmux()) {
|
|
424
|
+
throw new Error("psmux가 설치되어 있지 않습니다.");
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
const { paneId } = getWorkerStatus(sessionName, workerName);
|
|
428
|
+
return psmuxExec(`capture-pane -t ${quoteArg(paneId)} -p -S -${lines}`);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
if (err.message.includes("워커를 찾을 수 없습니다")) throw err;
|
|
431
|
+
throw new Error(`출력 캡처 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ─── CLI 진입점 ───
|
|
436
|
+
|
|
437
|
+
if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
|
|
438
|
+
const [,, cmd, ...args] = process.argv;
|
|
439
|
+
|
|
440
|
+
// CLI 인자 파싱 헬퍼
|
|
441
|
+
function getArg(name) {
|
|
442
|
+
const idx = args.indexOf(`--${name}`);
|
|
443
|
+
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
switch (cmd) {
|
|
448
|
+
case "spawn": {
|
|
449
|
+
const session = getArg("session");
|
|
450
|
+
const name = getArg("name");
|
|
451
|
+
const workerCmd = getArg("cmd");
|
|
452
|
+
if (!session || !name || !workerCmd) {
|
|
453
|
+
console.error("사용법: node psmux.mjs spawn --session <세션> --name <워커명> --cmd <커맨드>");
|
|
454
|
+
process.exit(1);
|
|
455
|
+
}
|
|
456
|
+
const result = spawnWorker(session, name, workerCmd);
|
|
457
|
+
console.log(JSON.stringify(result, null, 2));
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
case "status": {
|
|
461
|
+
const session = getArg("session");
|
|
462
|
+
const name = getArg("name");
|
|
463
|
+
if (!session || !name) {
|
|
464
|
+
console.error("사용법: node psmux.mjs status --session <세션> --name <워커명>");
|
|
465
|
+
process.exit(1);
|
|
466
|
+
}
|
|
467
|
+
const result = getWorkerStatus(session, name);
|
|
468
|
+
console.log(JSON.stringify(result, null, 2));
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
case "kill": {
|
|
472
|
+
const session = getArg("session");
|
|
473
|
+
const name = getArg("name");
|
|
474
|
+
if (!session || !name) {
|
|
475
|
+
console.error("사용법: node psmux.mjs kill --session <세션> --name <워커명>");
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
const result = killWorker(session, name);
|
|
479
|
+
console.log(JSON.stringify(result, null, 2));
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
case "output": {
|
|
483
|
+
const session = getArg("session");
|
|
484
|
+
const name = getArg("name");
|
|
485
|
+
const lines = parseInt(getArg("lines") || "50", 10);
|
|
486
|
+
if (!session || !name) {
|
|
487
|
+
console.error("사용법: node psmux.mjs output --session <세션> --name <워커명> [--lines <줄수>]");
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
console.log(captureWorkerOutput(session, name, lines));
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
default:
|
|
494
|
+
console.error("사용법: node psmux.mjs spawn|status|kill|output [args]");
|
|
495
|
+
console.error("");
|
|
496
|
+
console.error(" spawn --session <세션> --name <워커명> --cmd <커맨드>");
|
|
497
|
+
console.error(" status --session <세션> --name <워커명>");
|
|
498
|
+
console.error(" kill --session <세션> --name <워커명>");
|
|
499
|
+
console.error(" output --session <세션> --name <워커명> [--lines <줄수>]");
|
|
500
|
+
process.exit(1);
|
|
295
501
|
}
|
|
502
|
+
} catch (err) {
|
|
503
|
+
console.error(`오류: ${err.message}`);
|
|
504
|
+
process.exit(1);
|
|
296
505
|
}
|
|
297
506
|
}
|