triflux 10.8.0 → 10.9.0

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.
@@ -190,6 +190,69 @@
190
190
  "state": null,
191
191
  "mcp_route": null
192
192
  },
193
+ {
194
+ "id": "wt-tab-route",
195
+ "patterns": [
196
+ { "source": "(?:새\\s*탭|탭\\s*(?:새로|추가|생성|열|띄|파|만들))", "flags": "i" },
197
+ { "source": "(?:패인|화면|pane)\\s*(?:분할|나눠|스플릿|split)", "flags": "i" },
198
+ { "source": "\\bwt\\b\\s*(?:에\\s*)?(?:탭|tab|열|띄|새)", "flags": "i" },
199
+ { "source": "(?:터미널|terminal)\\s*(?:탭|새\\s*탭)", "flags": "i" },
200
+ { "source": "\\b(?:new\\s+tab|split\\s+pane|open\\s+(?:new\\s+)?tab)\\b", "flags": "i" }
201
+ ],
202
+ "skill": null,
203
+ "action": "context_hint",
204
+ "hint": "WT 탭/패인 생성 요청입니다. wt.exe 직접 호출은 safety-guard가 차단합니다.\n\nhub/team/wt-manager.mjs의 createWtManager() 팩토리로 인스턴스를 만든 뒤 API를 호출하세요:\n- wt.createTab({ title, command, profile, cwd }) — 새 탭\n- wt.splitPane({ direction: 'H'|'V', title, command }) — 패인 분할\n- wt.applySplitLayout([{ title, command, direction }]) — 다중 배치\n- wt.closeTab(title) — 탭 닫기\n\n사용법:\nnode -e \"import('./hub/team/wt-manager.mjs').then(m => { const wt = m.createWtManager(); wt.createTab({ title: 'MyTab', command: 'pwsh' }); })\"",
205
+ "priority": 1,
206
+ "supersedes": ["tfx-unified"],
207
+ "exclusive": false,
208
+ "state": null,
209
+ "mcp_route": null
210
+ },
211
+ {
212
+ "id": "wt-tab-rename",
213
+ "patterns": [
214
+ { "source": "(?:탭\\s*(?:이름|제목)\\s*(?:바꿔|변경|rename))", "flags": "i" },
215
+ { "source": "(?:rename\\s+tab|tab\\s+rename)", "flags": "i" }
216
+ ],
217
+ "skill": null,
218
+ "action": "context_hint",
219
+ "hint": "WT 탭 이름 변경 요청입니다. wt.exe 직접 호출은 safety-guard가 차단합니다.\n\nhub/team/wt-manager.mjs의 createWtManager() 팩토리로 인스턴스를 만든 뒤 API를 호출하세요:\n- wt.renameTab({ oldTitle, newTitle }) — 탭 이름 변경\n\n사용법:\nnode -e \"import('./hub/team/wt-manager.mjs').then(m => { const wt = m.createWtManager(); wt.renameTab({ oldTitle: 'OldName', newTitle: 'NewName' }); })\"",
220
+ "priority": 1,
221
+ "supersedes": ["tfx-unified"],
222
+ "exclusive": false,
223
+ "state": null,
224
+ "mcp_route": null
225
+ },
226
+ {
227
+ "id": "wt-tab-list",
228
+ "patterns": [
229
+ { "source": "(?:탭\\s*(?:목록|리스트|열린|현재))", "flags": "i" },
230
+ { "source": "(?:list\\s+tabs|tab\\s+list|열린\\s*탭)", "flags": "i" }
231
+ ],
232
+ "skill": null,
233
+ "action": "context_hint",
234
+ "hint": "WT 탭 목록 조회 요청입니다. wt.exe 직접 호출은 safety-guard가 차단합니다.\n\nhub/team/wt-manager.mjs의 createWtManager() 팩토리로 인스턴스를 만든 뒤 API를 호출하세요:\n- wt.listTabs() — 현재 열린 탭 목록 (pid 파일 기반)\n\n사용법:\nnode -e \"import('./hub/team/wt-manager.mjs').then(m => { const wt = m.createWtManager(); console.log(wt.listTabs()); })\"",
235
+ "priority": 1,
236
+ "supersedes": ["tfx-unified"],
237
+ "exclusive": false,
238
+ "state": null,
239
+ "mcp_route": null
240
+ },
241
+ {
242
+ "id": "wt-tab-close",
243
+ "patterns": [
244
+ { "source": "(?:탭\\s*(?:닫아|닫기|종료|정리|close))", "flags": "i" },
245
+ { "source": "(?:close\\s+tab|tab\\s+close|탭\\s*정리)", "flags": "i" }
246
+ ],
247
+ "skill": null,
248
+ "action": "context_hint",
249
+ "hint": "WT 탭 닫기 요청입니다. wt.exe 직접 호출은 safety-guard가 차단합니다.\n\nhub/team/wt-manager.mjs의 createWtManager() 팩토리로 인스턴스를 만든 뒤 API를 호출하세요:\n- wt.closeTab(title) — 제목으로 탭 닫기\n- wt.closeStale({ olderThanMs, titlePattern }) — 오래된/패턴 매칭 탭 정리\n\n사용법:\nnode -e \"import('./hub/team/wt-manager.mjs').then(m => { const wt = m.createWtManager(); wt.closeTab('MyTab'); })\"",
250
+ "priority": 1,
251
+ "supersedes": ["tfx-unified"],
252
+ "exclusive": false,
253
+ "state": null,
254
+ "mcp_route": null
255
+ },
193
256
  {
194
257
  "id": "handoff-route",
195
258
  "patterns": [
@@ -65,7 +65,13 @@ const WT_DIRECT_PATTERNS = [
65
65
  ];
66
66
 
67
67
  const WT_DIRECT_BLOCK_MESSAGE =
68
- "[safety-guard] wt.exe 직접 호출 차단됨. → hub/team/wt-manager.mjs의 createTab() / splitPane() / applySplitLayout()을 사용하세요.";
68
+ "[safety-guard] wt.exe 직접 호출 차단됨.\n" +
69
+ "→ hub/team/wt-manager.mjs의 createWtManager() 팩토리 사용:\n" +
70
+ " wt.createTab({ title, command, profile, cwd }) — 새 탭\n" +
71
+ " wt.splitPane({ direction: 'H'|'V', title, command }) — 패인 분할\n" +
72
+ " wt.applySplitLayout([{ title, command, direction }]) — 다중 배치\n" +
73
+ '사용법: node -e "import(\'./hub/team/wt-manager.mjs\').then(m => { const wt = m.createWtManager(); wt.createTab({ title: \'제목\', command: \'pwsh\' }); })"';
74
+
69
75
 
70
76
  // ── SSH+PowerShell bash 문법 차단 ────────────────────────────
71
77
  // 원격 기본 셸이 PowerShell인 호스트에 bash redirect/glob을 보내면 오동작
@@ -0,0 +1,85 @@
1
+ // hub/lib/env-detect.mjs — 쉘/터미널/멀티플렉서 환경 감지
2
+ import { execFileSync } from "node:child_process";
3
+ import { platform as osPlatform } from "node:os";
4
+
5
+ let _cached = null;
6
+
7
+ /**
8
+ * 기본 쉘 감지
9
+ * Windows: pwsh → powershell
10
+ * Unix: $SHELL → /bin/sh
11
+ */
12
+ export function detectShell() {
13
+ const platform = osPlatform();
14
+ if (platform === "win32") {
15
+ try {
16
+ execFileSync("where", ["pwsh.exe"], { stdio: "ignore", timeout: 3000 });
17
+ return { name: "pwsh", path: "pwsh.exe", version: null };
18
+ } catch {
19
+ try {
20
+ execFileSync("where", ["powershell.exe"], { stdio: "ignore", timeout: 3000 });
21
+ return { name: "powershell", path: "powershell.exe", version: null };
22
+ } catch {
23
+ return { name: "cmd", path: "cmd.exe", version: null, installHint: "pwsh: winget install Microsoft.PowerShell" };
24
+ }
25
+ }
26
+ }
27
+
28
+ const shellPath = process.env.SHELL || "/bin/sh";
29
+ const name = shellPath.split("/").pop() || "sh";
30
+ return { name, path: shellPath, version: null };
31
+ }
32
+
33
+ /**
34
+ * 터미널 에뮬레이터 감지
35
+ */
36
+ export function detectTerminal() {
37
+ const platform = osPlatform();
38
+ if (platform === "win32") {
39
+ try {
40
+ execFileSync("where", ["wt.exe"], { stdio: "ignore", timeout: 3000 });
41
+ return { name: "windows-terminal", hasWt: true };
42
+ } catch {
43
+ return { name: "conhost", hasWt: false, installHint: "Windows Terminal: winget install Microsoft.WindowsTerminal" };
44
+ }
45
+ }
46
+
47
+ if (process.env.TERM_PROGRAM === "iTerm.app") {
48
+ return { name: "iterm2", hasWt: false };
49
+ }
50
+ if (process.env.TERM_PROGRAM === "Apple_Terminal") {
51
+ return { name: "terminal-app", hasWt: false };
52
+ }
53
+
54
+ return { name: "unknown", hasWt: false };
55
+ }
56
+
57
+ /**
58
+ * 멀티플렉서 감지 (tmux)
59
+ */
60
+ export function detectMultiplexer() {
61
+ try {
62
+ const cmd = osPlatform() === "win32" ? "where" : "which";
63
+ const path = execFileSync(cmd, ["tmux"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
64
+ return { name: "tmux", path };
65
+ } catch {
66
+ return { name: "none", path: null };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 통합 환경 정보 조회 (레이지 싱글톤 캐시)
72
+ * @returns {{ shell: object, terminal: object, multiplexer: object, platform: string }}
73
+ */
74
+ export function getEnvironment() {
75
+ if (_cached) return _cached;
76
+
77
+ _cached = Object.freeze({
78
+ shell: detectShell(),
79
+ terminal: detectTerminal(),
80
+ multiplexer: detectMultiplexer(),
81
+ platform: osPlatform(),
82
+ });
83
+
84
+ return _cached;
85
+ }
@@ -19,7 +19,7 @@ export async function startWtTeam({
19
19
  warn(`wt 모드에서 ${layout} 레이아웃은 미지원 — ${effectiveLayout}로 대체`);
20
20
  console.log(` 레이아웃: ${effectiveLayout} (${paneCount} panes)`);
21
21
 
22
- const session = createWtSession(sessionId, {
22
+ const session = await createWtSession(sessionId, {
23
23
  layout: effectiveLayout,
24
24
  paneCommands: [
25
25
  { title: `${sessionId}-lead`, command: buildCliCommand(lead) },
@@ -28,7 +28,7 @@ import {
28
28
  } from "./conductor-registry.mjs";
29
29
  import { createEventLog } from "./event-log.mjs";
30
30
  import { createHealthProbe } from "./health-probe.mjs";
31
- import { buildLauncher } from "./launcher-template.mjs";
31
+ import { buildLauncher, getAdapter } from "./launcher-template.mjs";
32
32
  import { createRemoteProbe } from "./remote-probe.mjs";
33
33
 
34
34
  /** 세션 상태 */
@@ -335,7 +335,11 @@ export function createConductor(opts = {}) {
335
335
  const backoffMs = Math.min(1000 * 2 ** (session.restarts - 1), 30_000);
336
336
  session._respawnTimer = setTimeout(() => {
337
337
  session._respawnTimer = null;
338
- void respawnSession(session);
338
+ if (session.config.remote) {
339
+ startRemoteSession(session);
340
+ } else {
341
+ void respawnSession(session);
342
+ }
339
343
  }, backoffMs);
340
344
  } else {
341
345
  transition(session, STATES.DEAD, `maxRestarts(${maxRestarts})_exceeded`);
@@ -400,6 +404,10 @@ export function createConductor(opts = {}) {
400
404
  session.outPath = outPath;
401
405
  session.errPath = errPath;
402
406
 
407
+ // 로컬 세션: 프롬프트는 CLI args로 전달되므로 stdin 즉시 닫기
408
+ // (codex가 stdin pipe 감지 시 "Reading additional input..." 대기 방지)
409
+ if (child.stdin) child.stdin.end();
410
+
403
411
  eventLog.append("spawn", {
404
412
  session: session.id,
405
413
  agent: session.config.agent,
@@ -510,40 +518,159 @@ export function createConductor(opts = {}) {
510
518
  }
511
519
 
512
520
  /**
513
- * 원격 세션 시작 — child process 대신 SSH capture-pane 폴링.
514
- * 원격 세션은 remote-spawn.mjs가 이미 psmux 세션을 생성한 상태를 가정.
521
+ * 원격 세션 시작 — SSH child process 직접 실행.
522
+ * psmux 불필요. 원격 호스트에서 claude -p SSH 경유로 실행하고
523
+ * 로컬 child process로 관리한다.
515
524
  */
516
525
  function startRemoteSession(session) {
517
526
  transition(session, STATES.STARTING, "remote_initial");
518
527
 
519
- const { host, paneTarget, sessionName } = session.config;
520
- const resolvedPane = paneTarget || `${sessionName || session.id}:0.0`;
521
- const resolvedSessionName = sessionName || session.id;
528
+ const { host, prompt, agent, workdir: remoteWorkdir } = session.config;
529
+
530
+ // SSH args를 배열로 구성하여 셸 이스케이프 문제 회피
531
+ const cdPrefix = remoteWorkdir ? `cd ${remoteWorkdir} && ` : "";
532
+
533
+ let remoteBin;
534
+ if (agent === "claude") {
535
+ remoteBin = "claude --output-format text";
536
+ } else if (agent === "gemini") {
537
+ remoteBin = "gemini -y";
538
+ } else {
539
+ remoteBin = "codex exec -s danger-full-access --dangerously-bypass-approvals-and-sandbox";
540
+ }
541
+
542
+ // prompt는 stdin으로 전달 — 셸 이스케이프 문제 완전 회피
543
+ const sshArgs = [
544
+ "-o", "ConnectTimeout=30",
545
+ "-o", "BatchMode=yes",
546
+ host,
547
+ `${cdPrefix}${remoteBin}`,
548
+ ];
549
+
550
+ const sshCmd = `ssh ${sshArgs.join(" ")}`;
522
551
 
523
552
  eventLog.append("remote_start", {
524
553
  session: session.id,
525
554
  host,
526
- paneTarget: resolvedPane,
527
- sessionName: resolvedSessionName,
555
+ command: sshCmd,
528
556
  });
529
557
 
558
+ const outPath = join(logsDir, `${session.id}.out.log`);
559
+ const errPath = join(logsDir, `${session.id}.err.log`);
560
+ const outWs = createWriteStream(outPath, { flags: "a" });
561
+ const errWs = createWriteStream(errPath, { flags: "a" });
562
+
563
+ let child;
564
+ try {
565
+ child = spawn("ssh", sshArgs, {
566
+ env: process.env,
567
+ reason: `conductor:remoteSession:${session.id}`,
568
+ stdio: ["pipe", "pipe", "pipe"],
569
+ windowsHide: true,
570
+ });
571
+ } catch (err) {
572
+ eventLog.append("spawn_error", {
573
+ session: session.id,
574
+ error: err.message,
575
+ });
576
+ handleFailure(session, `spawn_error:${err.message}`);
577
+ return;
578
+ }
579
+
580
+ session.child = child;
581
+ session.outPath = outPath;
582
+ session.errPath = errPath;
530
583
  session.alive = true;
531
584
 
532
- // Remote health probe 설정
533
- session.probe?.stop();
534
- const probe = createRemoteProbe(
535
- {
585
+ // stdin으로 프롬프트 전달 — 셸 이스케이프 완전 우회
586
+ if (child.stdin) {
587
+ child.stdin.write(prompt);
588
+ child.stdin.end();
589
+ }
590
+
591
+ eventLog.append("spawn", {
592
+ session: session.id,
593
+ agent,
594
+ pid: child.pid,
595
+ command: sshCmd,
596
+ remote: true,
597
+ host,
598
+ });
599
+
600
+ // stdout/stderr 추적
601
+ let outputBytes = 0;
602
+ const trackOutput = (buf) => {
603
+ outputBytes += buf.length;
604
+ };
605
+
606
+ child.stdout?.on("data", (buf) => {
607
+ trackOutput(buf);
608
+ outWs.write(buf);
609
+ });
610
+ child.stderr?.on("data", (buf) => {
611
+ trackOutput(buf);
612
+ errWs.write(buf);
613
+ });
614
+
615
+ // 상태를 healthy로 전이
616
+ transition(session, STATES.HEALTHY, "remote_ssh_spawned");
617
+
618
+ // Health probe — 주기적으로 출력 변화 체크 (F3 stall 감지)
619
+ let lastBytes = 0;
620
+ let stallChecks = 0;
621
+ const stallThreshold = probeOpts.stallChecks || 8; // 8 * intervalMs = stall
622
+ const healthInterval = setInterval(() => {
623
+ if (TERMINAL_STATES.has(session.state)) {
624
+ clearInterval(healthInterval);
625
+ return;
626
+ }
627
+ if (outputBytes > lastBytes) {
628
+ lastBytes = outputBytes;
629
+ stallChecks = 0;
630
+ if (session.state !== STATES.HEALTHY) {
631
+ transition(session, STATES.HEALTHY, "output_advancing");
632
+ }
633
+ } else {
634
+ stallChecks++;
635
+ if (stallChecks >= stallThreshold && session.state === STATES.HEALTHY) {
636
+ transition(session, STATES.STALLED, "remote_no_output");
637
+ }
638
+ }
639
+ }, probeOpts.intervalMs || 5000);
640
+
641
+ // exit 핸들러
642
+ child.once("exit", (code) => {
643
+ clearInterval(healthInterval);
644
+ outWs.end();
645
+ errWs.end();
646
+ session.alive = false;
647
+
648
+ eventLog.append("remote_exit", {
649
+ session: session.id,
650
+ code,
651
+ outputBytes,
536
652
  host,
537
- paneTarget: resolvedPane,
538
- sessionName: resolvedSessionName,
539
- },
540
- {
541
- ...probeOpts,
542
- onProbe: (result) => handleProbeResult(session, result),
543
- },
544
- );
545
- session.probe = probe;
546
- probe.start();
653
+ });
654
+
655
+ if (code === 0) {
656
+ transition(session, STATES.COMPLETED, `exit_${code}`);
657
+ emitter.emit("completed", { sessionId: session.id });
658
+ maybeAutoShutdown();
659
+ } else {
660
+ handleFailure(session, `remote_exit_${code}`);
661
+ }
662
+ });
663
+
664
+ child.once("error", (err) => {
665
+ clearInterval(healthInterval);
666
+ outWs.end();
667
+ errWs.end();
668
+ eventLog.append("spawn_error", {
669
+ session: session.id,
670
+ error: err.message,
671
+ });
672
+ handleFailure(session, `spawn_error:${err.message}`);
673
+ });
547
674
  }
548
675
 
549
676
  /**
@@ -1,5 +1,3 @@
1
- import { spawn } from "../lib/spawn-trace.mjs";
2
-
3
1
  import { psmuxExec } from "./psmux.mjs";
4
2
  import {
5
3
  detectMultiplexer,
@@ -8,6 +6,7 @@ import {
8
6
  resolveAttachCommand,
9
7
  tmuxExec,
10
8
  } from "./session.mjs";
9
+ import { createWtManager } from "./wt-manager.mjs";
11
10
 
12
11
  function sanitizeWindowTitle(value, fallback = "triflux") {
13
12
  const text = String(value || "")
@@ -44,9 +43,10 @@ export function decideDashboardOpenMode({
44
43
  return hasWtSession ? "split" : "window";
45
44
  }
46
45
 
47
- function spawnWindowsTerminal(spec, opts = {}) {
46
+ async function spawnWindowsTerminal(spec, opts = {}) {
48
47
  if (!hasWindowsTerminal()) return false;
49
48
 
49
+ const wt = createWtManager();
50
50
  const {
51
51
  mode = "window",
52
52
  title = "triflux",
@@ -56,35 +56,29 @@ function spawnWindowsTerminal(spec, opts = {}) {
56
56
 
57
57
  const safeTitle = sanitizeWindowTitle(title);
58
58
  const safeCwd = sanitizeWorkingDirectory(cwd);
59
- const orientation = split?.orientation === "V" ? "V" : "H";
60
- const size = Number.isFinite(split?.size)
61
- ? Math.min(0.8, Math.max(0.2, split.size))
62
- : 0.5;
63
- const baseArgs = [
64
- "--profile",
65
- "triflux",
66
- "--title",
67
- safeTitle,
68
- "-d",
69
- safeCwd,
70
- "--",
71
- spec.command,
72
- ...spec.args,
73
- ];
74
- const args =
75
- mode === "split"
76
- ? ["-w", "0", "sp", `-${orientation}`, "-s", String(size), ...baseArgs]
77
- : mode === "tab"
78
- ? ["-w", "0", "nt", ...baseArgs]
79
- : ["-w", "new", ...baseArgs];
80
-
81
- const child = spawn("wt.exe", args, {
82
- detached: true,
83
- stdio: "ignore",
84
- windowsHide: false,
85
- });
86
- child.unref();
87
- return true;
59
+
60
+ try {
61
+ if (mode === "split") {
62
+ await wt.splitPane({
63
+ direction: split?.orientation === "V" ? "V" : "H",
64
+ size: (split?.size || 0.5) * 100,
65
+ title: safeTitle,
66
+ cwd: safeCwd,
67
+ command: spec.args ? `${spec.command} ${spec.args.join(" ")}` : spec.command,
68
+ profile: "triflux",
69
+ });
70
+ } else {
71
+ await wt.createTab({
72
+ title: safeTitle,
73
+ cwd: safeCwd,
74
+ command: spec.args ? `${spec.command} ${spec.args.join(" ")}` : spec.command,
75
+ profile: "triflux",
76
+ });
77
+ }
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
88
82
  }
89
83
 
90
84
  export function focusManagedPane(target, opts = {}) {
@@ -122,7 +116,7 @@ export function openHeadlessDashboardTarget(sessionName, opts = {}) {
122
116
  }
123
117
 
124
118
  // 전체 열기 (Shift+Enter) → 새 WT 창으로 세션 attach
125
- return spawnWindowsTerminal(
119
+ void spawnWindowsTerminal(
126
120
  { command: "psmux", args: ["attach-session", "-t", safeSession] },
127
121
  {
128
122
  mode: decideDashboardOpenMode({ openAll }),
@@ -130,6 +124,7 @@ export function openHeadlessDashboardTarget(sessionName, opts = {}) {
130
124
  cwd,
131
125
  },
132
126
  );
127
+ return true;
133
128
  }
134
129
 
135
130
  export function openDashboardRuntimeTarget(runtime, opts = {}) {
@@ -162,11 +157,12 @@ export function openDashboardRuntimeTarget(runtime, opts = {}) {
162
157
  try {
163
158
  if (!openAll && targetPane)
164
159
  focusManagedPane(targetPane, { teammateMode, layout });
165
- return spawnWindowsTerminal(resolveAttachCommand(sessionName), {
160
+ void spawnWindowsTerminal(resolveAttachCommand(sessionName), {
166
161
  mode: decideDashboardOpenMode({ openAll }),
167
162
  title: title || `▲ ${sanitizeSessionName(sessionName)}`,
168
163
  cwd,
169
164
  });
165
+ return true;
170
166
  } catch {
171
167
  return false;
172
168
  }