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/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-team-abc:0.1
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-team-abc:0.1
93
- * @param {string} prompt — 주입할 텍스트
94
- */
95
- export function injectPrompt(target, prompt) {
96
- // 임시 파일에 프롬프트 저장
97
- const tmpDir = join(tmpdir(), "tfx-team");
98
- mkdirSync(tmpDir, { recursive: true });
99
-
100
- // pane ID를 파일명에 포함 (충돌 방지)
101
- const safeTarget = target.replace(/[:.]/g, "-");
102
- const tmpFile = join(tmpDir, `prompt-${safeTarget}-${Date.now()}.txt`);
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
- try {
124
- unlinkSync(tmpFile);
125
- } catch {
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
  }
@@ -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-team- 접두사 psmux 세션 목록
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-team-"));
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
- psmuxExec(`bind-key -T root -n S-Down if-shell -F '${cond}' ${bindNext} 'send-keys S-Down'`);
279
- psmuxExec(`bind-key -T root -n S-Up if-shell -F '${cond}' ${bindPrev} 'send-keys S-Up'`);
280
- psmuxExec(`bind-key -T root -n S-Right if-shell -F '${cond}' ${bindNext} 'send-keys S-Right'`);
281
- psmuxExec(`bind-key -T root -n S-Left if-shell -F '${cond}' ${bindPrev} 'send-keys S-Left'`);
282
- psmuxExec(`bind-key -T root -n BTab if-shell -F '${cond}' ${bindPrev} 'send-keys BTab'`);
283
- psmuxExec(`bind-key -T root -n Escape if-shell -F '${cond}' 'send-keys C-c' 'send-keys Escape'`);
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
- psmuxExec(
293
- `bind-key -T root -n C-t if-shell -F '${cond}' 'display-message "tfx team tasks 명령으로 태스크 확인"' 'send-keys C-t'`
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
  }