triflux 3.2.0-dev.1 → 3.2.0-dev.10

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.
Files changed (53) hide show
  1. package/README.ko.md +26 -18
  2. package/README.md +26 -18
  3. package/bin/triflux.mjs +1614 -1084
  4. package/hooks/hooks.json +12 -0
  5. package/hooks/keyword-rules.json +354 -0
  6. package/hub/bridge.mjs +371 -193
  7. package/hub/hitl.mjs +45 -31
  8. package/hub/pipe.mjs +457 -0
  9. package/hub/router.mjs +422 -161
  10. package/hub/server.mjs +429 -344
  11. package/hub/store.mjs +388 -314
  12. package/hub/team/cli-team-common.mjs +348 -0
  13. package/hub/team/cli-team-control.mjs +393 -0
  14. package/hub/team/cli-team-start.mjs +516 -0
  15. package/hub/team/cli-team-status.mjs +269 -0
  16. package/hub/team/cli.mjs +99 -368
  17. package/hub/team/dashboard.mjs +165 -64
  18. package/hub/team/native-supervisor.mjs +300 -0
  19. package/hub/team/native.mjs +62 -0
  20. package/hub/team/nativeProxy.mjs +534 -0
  21. package/hub/team/orchestrator.mjs +99 -35
  22. package/hub/team/pane.mjs +138 -101
  23. package/hub/team/psmux.mjs +297 -0
  24. package/hub/team/session.mjs +608 -186
  25. package/hub/team/shared.mjs +13 -0
  26. package/hub/team/staleState.mjs +299 -0
  27. package/hub/tools.mjs +140 -53
  28. package/hub/workers/claude-worker.mjs +446 -0
  29. package/hub/workers/codex-mcp.mjs +414 -0
  30. package/hub/workers/factory.mjs +18 -0
  31. package/hub/workers/gemini-worker.mjs +349 -0
  32. package/hub/workers/interface.mjs +41 -0
  33. package/hud/hud-qos-status.mjs +1789 -1732
  34. package/package.json +6 -2
  35. package/scripts/__tests__/keyword-detector.test.mjs +234 -0
  36. package/scripts/hub-ensure.mjs +83 -0
  37. package/scripts/keyword-detector.mjs +272 -0
  38. package/scripts/keyword-rules-expander.mjs +521 -0
  39. package/scripts/lib/keyword-rules.mjs +168 -0
  40. package/scripts/psmux-steering-prototype.sh +368 -0
  41. package/scripts/run.cjs +62 -0
  42. package/scripts/setup.mjs +189 -7
  43. package/scripts/test-tfx-route-no-claude-native.mjs +49 -0
  44. package/scripts/tfx-route-worker.mjs +161 -0
  45. package/scripts/tfx-route.sh +943 -508
  46. package/skills/tfx-auto/SKILL.md +90 -564
  47. package/skills/tfx-auto-codex/SKILL.md +77 -0
  48. package/skills/tfx-codex/SKILL.md +1 -4
  49. package/skills/tfx-doctor/SKILL.md +1 -0
  50. package/skills/tfx-gemini/SKILL.md +1 -4
  51. package/skills/tfx-multi/SKILL.md +296 -0
  52. package/skills/tfx-setup/SKILL.md +1 -4
  53. package/skills/tfx-team/SKILL.md +0 -172
package/hub/team/pane.mjs CHANGED
@@ -1,101 +1,138 @@
1
- // hub/team/pane.mjs — pane별 CLI 실행 + stdin 주입
2
- // 의존성: child_process, fs, os, path (Node.js 내장)만 사용
3
- import { execSync } from "node:child_process";
4
- import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { tmpdir } from "node:os";
7
- import { detectMultiplexer } from "./session.mjs";
8
-
9
- /** Windows 경로를 MSYS2/Git Bash tmux용 POSIX 경로로 변환 */
10
- function toTmuxPath(p) {
11
- if (process.platform !== "win32") return p;
12
- // C:\Users\... → /c/Users/...
13
- return p.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, d) => `/${d.toLowerCase()}`);
14
- }
15
-
16
- /** tmux 커맨드 실행 (session.mjs와 동일 패턴) */
17
- function tmux(args, opts = {}) {
18
- const mux = detectMultiplexer();
19
- if (!mux) throw new Error("tmux 미발견");
20
- const prefix = mux === "wsl-tmux" ? "wsl tmux" : "tmux";
21
- const result = execSync(`${prefix} ${args}`, {
22
- encoding: "utf8",
23
- timeout: 10000,
24
- stdio: ["pipe", "pipe", "pipe"],
25
- ...opts,
26
- });
27
- return result != null ? result.trim() : "";
28
- }
29
-
30
- /**
31
- * CLI 에이전트 시작 커맨드 생성
32
- * @param {'codex'|'gemini'|'claude'} cli
33
- * @returns {string} 실행할 셸 커맨드
34
- */
35
- export function buildCliCommand(cli) {
36
- switch (cli) {
37
- case "codex":
38
- // interactive REPL 진입 — MCP~/.codex/config.json에 사전 등록
39
- return "codex";
40
- case "gemini":
41
- // interactive 모드 — MCP는 ~/.gemini/settings.json에 사전 등록
42
- return "gemini";
43
- case "claude":
44
- // interactive 모드
45
- return "claude";
46
- default:
47
- return cli; // 커스텀 CLI 허용
48
- }
49
- }
50
-
51
- /**
52
- * tmux pane에 CLI 시작
53
- * @param {string} target — 예: tfx-team-abc:0.1
54
- * @param {string} command 실행할 커맨드
55
- */
56
- export function startCliInPane(target, command) {
57
- // 특수문자 이스케이프: 작은따옴표 내부에서 안전하도록
58
- const escaped = command.replace(/'/g, "'\\''");
59
- tmux(`send-keys -t ${target} '${escaped}' Enter`);
60
- }
61
-
62
- /**
63
- * pane에 프롬프트 주입 (load-buffer + paste-buffer 방식)
64
- * 멀티라인 + 특수문자 안전, 크기 제한 없음
65
- * @param {string} target — 예: tfx-team-abc:0.1
66
- * @param {string} prompt — 주입할 텍스트
67
- */
68
- export function injectPrompt(target, prompt) {
69
- // 임시 파일에 프롬프트 저장
70
- const tmpDir = join(tmpdir(), "tfx-team");
71
- mkdirSync(tmpDir, { recursive: true });
72
-
73
- // pane ID를 파일명에 포함 (충돌 방지)
74
- const safeTarget = target.replace(/[:.]/g, "-");
75
- const tmpFile = join(tmpDir, `prompt-${safeTarget}-${Date.now()}.txt`);
76
-
77
- try {
78
- writeFileSync(tmpFile, prompt, "utf8");
79
-
80
- // tmux load-buffer paste-buffer → Enter (Windows 경로 변환 필요)
81
- tmux(`load-buffer ${toTmuxPath(tmpFile)}`);
82
- tmux(`paste-buffer -t ${target}`);
83
- tmux(`send-keys -t ${target} Enter`);
84
- } finally {
85
- // 임시 파일 정리
86
- try {
87
- unlinkSync(tmpFile);
88
- } catch {
89
- // 정리 실패 무시
90
- }
91
- }
92
- }
93
-
94
- /**
95
- * pane에 입력 전송
96
- * @param {string} target — 예: tfx-team-abc:0.1
97
- * @param {string} keys — tmux 키 표현 (예: 'C-c', 'Enter')
98
- */
99
- export function sendKeys(target, keys) {
100
- tmux(`send-keys -t ${target} ${keys}`);
101
- }
1
+ // hub/team/pane.mjs — pane별 CLI 실행 + stdin 주입
2
+ // 의존성: child_process, fs, os, path (Node.js 내장)만 사용
3
+ import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { detectMultiplexer, tmuxExec } from "./session.mjs";
7
+ import { psmuxExec } from "./psmux.mjs";
8
+
9
+ function quoteArg(value) {
10
+ return `"${String(value).replace(/"/g, '\\"')}"`;
11
+ }
12
+
13
+ function getPsmuxSessionName(target) {
14
+ return String(target).split(":")[0]?.trim() || "";
15
+ }
16
+
17
+ /** Windows 경로를 멀티플렉서용 경로로 변환 */
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
+
42
+ /** 멀티플렉서 커맨드 실행 (session.mjs와 동일 패턴) */
43
+ function muxExec(args, opts = {}) {
44
+ const exec = detectMultiplexer() === "psmux" ? psmuxExec : tmuxExec;
45
+ return exec(args, {
46
+ encoding: "utf8",
47
+ timeout: 10000,
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
+ /**
80
+ * pane에 CLI 시작
81
+ * @param {string} target — 예: tfx-multi-abc:0.1
82
+ * @param {string} command — 실행할 커맨드
83
+ */
84
+ export function startCliInPane(target, command) {
85
+ // CLI 시작도 buffer paste를 재사용해 셸/플랫폼별 quoting 차이를 제거한다.
86
+ injectPrompt(target, command);
87
+ }
88
+
89
+ /**
90
+ * pane에 프롬프트 주입 (load-buffer + paste-buffer 방식)
91
+ * 멀티라인 + 특수문자 안전, 크기 제한 없음
92
+ * @param {string} target — 예: tfx-multi-abc:0.1
93
+ * @param {string} prompt — 주입할 텍스트
94
+ */
95
+ export function injectPrompt(target, prompt) {
96
+ // 임시 파일에 프롬프트 저장
97
+ const tmpDir = join(tmpdir(), "tfx-multi");
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
+
104
+ try {
105
+ writeFileSync(tmpFile, prompt, "utf8");
106
+
107
+ // psmux는 buffer 명령에 세션 컨텍스트가 필요하다.
108
+ if (detectMultiplexer() === "psmux") {
109
+ const sessionName = getPsmuxSessionName(target);
110
+ psmuxExec(["load-buffer", "-t", sessionName, toMuxPath(tmpFile)]);
111
+ psmuxExec(["select-pane", "-t", target]);
112
+ psmuxExec(["paste-buffer", "-t", target]);
113
+ psmuxExec(["send-keys", "-t", target, "Enter"]);
114
+ return;
115
+ }
116
+
117
+ // tmux load-buffer → paste-buffer → Enter
118
+ muxExec(`load-buffer ${quoteArg(toMuxPath(tmpFile))}`);
119
+ muxExec(`paste-buffer -t ${target}`);
120
+ muxExec(`send-keys -t ${target} Enter`);
121
+ } finally {
122
+ // 임시 파일 정리
123
+ try {
124
+ unlinkSync(tmpFile);
125
+ } catch {
126
+ // 정리 실패 무시
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * pane에 키 입력 전송
133
+ * @param {string} target — 예: tfx-multi-abc:0.1
134
+ * @param {string} keys — tmux 키 표현 (예: 'C-c', 'Enter')
135
+ */
136
+ export function sendKeys(target, keys) {
137
+ muxExec(`send-keys -t ${target} ${keys}`);
138
+ }
@@ -0,0 +1,297 @@
1
+ // hub/team/psmux.mjs — Windows psmux 세션/키바인딩/캡처 관리
2
+ // 의존성: child_process (Node.js 내장)만 사용
3
+ import { execSync, spawnSync } from "node:child_process";
4
+
5
+ const PSMUX_BIN = process.env.PSMUX_BIN || "psmux";
6
+
7
+ function quoteArg(value) {
8
+ const str = String(value);
9
+ if (!/[\s"]/u.test(str)) return str;
10
+ return `"${str.replace(/"/g, '\\"')}"`;
11
+ }
12
+
13
+ function toPaneTitle(index) {
14
+ return index === 0 ? "lead" : `worker-${index}`;
15
+ }
16
+
17
+ function parsePaneList(output) {
18
+ return output
19
+ .split("\n")
20
+ .map((line) => line.trim())
21
+ .filter(Boolean)
22
+ .map((line) => {
23
+ const [indexText, target] = line.split("\t");
24
+ return {
25
+ index: parseInt(indexText, 10),
26
+ target: target?.trim() || "",
27
+ };
28
+ })
29
+ .filter((entry) => Number.isFinite(entry.index) && entry.target)
30
+ .sort((a, b) => a.index - b.index)
31
+ .map((entry) => entry.target);
32
+ }
33
+
34
+ function parseSessionSummaries(output) {
35
+ return output
36
+ .split("\n")
37
+ .map((line) => line.trim())
38
+ .filter(Boolean)
39
+ .map((line) => {
40
+ const colonIndex = line.indexOf(":");
41
+ if (colonIndex === -1) {
42
+ return null;
43
+ }
44
+
45
+ const sessionName = line.slice(0, colonIndex).trim();
46
+ const flags = [...line.matchAll(/\(([^)]*)\)/g)].map((match) => match[1]).join(", ");
47
+ const attachedMatch = flags.match(/(\d+)\s+attached/);
48
+ const attachedCount = attachedMatch
49
+ ? parseInt(attachedMatch[1], 10)
50
+ : /\battached\b/.test(flags)
51
+ ? 1
52
+ : 0;
53
+
54
+ return sessionName
55
+ ? { sessionName, attachedCount }
56
+ : null;
57
+ })
58
+ .filter(Boolean);
59
+ }
60
+
61
+ function collectSessionPanes(sessionName) {
62
+ const output = psmuxExec(
63
+ `list-panes -t ${quoteArg(`${sessionName}:0`)} -F "#{pane_index}\t#{session_name}:#{window_index}.#{pane_index}"`
64
+ );
65
+ return parsePaneList(output);
66
+ }
67
+
68
+ function psmux(args, opts = {}) {
69
+ if (Array.isArray(args)) {
70
+ const result = spawnSync(PSMUX_BIN, args.map((arg) => String(arg)), {
71
+ encoding: "utf8",
72
+ timeout: 10000,
73
+ stdio: ["pipe", "pipe", "pipe"],
74
+ windowsHide: true,
75
+ ...opts,
76
+ });
77
+ if ((result.status ?? 1) !== 0) {
78
+ const error = new Error((result.stderr || result.stdout || "psmux command failed").trim());
79
+ error.status = result.status;
80
+ throw error;
81
+ }
82
+ return (result.stdout || "").trim();
83
+ }
84
+
85
+ const result = execSync(`${quoteArg(PSMUX_BIN)} ${args}`, {
86
+ encoding: "utf8",
87
+ timeout: 10000,
88
+ stdio: ["pipe", "pipe", "pipe"],
89
+ windowsHide: true,
90
+ ...opts,
91
+ });
92
+ return result != null ? result.trim() : "";
93
+ }
94
+
95
+ /** psmux 실행 가능 여부 확인 */
96
+ export function hasPsmux() {
97
+ try {
98
+ execSync(`${quoteArg(PSMUX_BIN)} -V`, {
99
+ stdio: "ignore",
100
+ timeout: 3000,
101
+ windowsHide: true,
102
+ });
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * psmux 커맨드 실행 래퍼
111
+ * @param {string|string[]} args
112
+ * @param {object} opts
113
+ * @returns {string}
114
+ */
115
+ export function psmuxExec(args, opts = {}) {
116
+ return psmux(args, opts);
117
+ }
118
+
119
+ /**
120
+ * psmux 세션 생성 + 레이아웃 분할
121
+ * @param {string} sessionName
122
+ * @param {object} opts
123
+ * @param {'2x2'|'1xN'|'Nx1'} opts.layout
124
+ * @param {number} opts.paneCount
125
+ * @returns {{ sessionName: string, panes: string[] }}
126
+ */
127
+ export function createPsmuxSession(sessionName, opts = {}) {
128
+ const layout = opts.layout === "1xN" || opts.layout === "Nx1" ? opts.layout : "2x2";
129
+ const paneCount = Math.max(
130
+ 1,
131
+ Number.isFinite(opts.paneCount) ? Math.trunc(opts.paneCount) : 4
132
+ );
133
+ const limitedPaneCount = layout === "2x2" ? Math.min(paneCount, 4) : paneCount;
134
+ const sessionTarget = `${sessionName}:0`;
135
+
136
+ const leadPane = psmuxExec(
137
+ `new-session -d -P -F "#{session_name}:#{window_index}.#{pane_index}" -s ${quoteArg(sessionName)} -x 220 -y 55`
138
+ );
139
+
140
+ if (layout === "2x2" && limitedPaneCount >= 3) {
141
+ const rightPane = psmuxExec(
142
+ `split-window -h -P -F "#{session_name}:#{window_index}.#{pane_index}" -t ${quoteArg(leadPane)}`
143
+ );
144
+ psmuxExec(
145
+ `split-window -v -P -F "#{session_name}:#{window_index}.#{pane_index}" -t ${quoteArg(rightPane)}`
146
+ );
147
+ if (limitedPaneCount >= 4) {
148
+ psmuxExec(
149
+ `split-window -v -P -F "#{session_name}:#{window_index}.#{pane_index}" -t ${quoteArg(leadPane)}`
150
+ );
151
+ }
152
+ psmuxExec(`select-layout -t ${quoteArg(sessionTarget)} tiled`);
153
+ } else if (layout === "1xN") {
154
+ for (let i = 1; i < limitedPaneCount; i++) {
155
+ psmuxExec(`split-window -h -t ${quoteArg(sessionTarget)}`);
156
+ }
157
+ psmuxExec(`select-layout -t ${quoteArg(sessionTarget)} even-horizontal`);
158
+ } else {
159
+ for (let i = 1; i < limitedPaneCount; i++) {
160
+ psmuxExec(`split-window -v -t ${quoteArg(sessionTarget)}`);
161
+ }
162
+ psmuxExec(`select-layout -t ${quoteArg(sessionTarget)} even-vertical`);
163
+ }
164
+
165
+ psmuxExec(`select-pane -t ${quoteArg(leadPane)}`);
166
+
167
+ const panes = collectSessionPanes(sessionName).slice(0, limitedPaneCount);
168
+ panes.forEach((pane, index) => {
169
+ psmuxExec(`select-pane -t ${quoteArg(pane)} -T ${quoteArg(toPaneTitle(index))}`);
170
+ });
171
+
172
+ return { sessionName, panes };
173
+ }
174
+
175
+ /**
176
+ * psmux 세션 종료
177
+ * @param {string} sessionName
178
+ */
179
+ export function killPsmuxSession(sessionName) {
180
+ try {
181
+ psmuxExec(`kill-session -t ${quoteArg(sessionName)}`, { stdio: "ignore" });
182
+ } catch {
183
+ // 이미 종료된 세션 — 무시
184
+ }
185
+ }
186
+
187
+ /**
188
+ * psmux 세션 존재 확인
189
+ * @param {string} sessionName
190
+ * @returns {boolean}
191
+ */
192
+ export function psmuxSessionExists(sessionName) {
193
+ try {
194
+ psmuxExec(`has-session -t ${quoteArg(sessionName)}`, { stdio: "ignore" });
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * tfx-multi- 접두사 psmux 세션 목록
203
+ * @returns {string[]}
204
+ */
205
+ export function listPsmuxSessions() {
206
+ try {
207
+ return parseSessionSummaries(psmuxExec("list-sessions"))
208
+ .map((session) => session.sessionName)
209
+ .filter((sessionName) => sessionName.startsWith("tfx-multi-"));
210
+ } catch {
211
+ return [];
212
+ }
213
+ }
214
+
215
+ /**
216
+ * pane 마지막 N줄 캡처
217
+ * @param {string} target
218
+ * @param {number} lines
219
+ * @returns {string}
220
+ */
221
+ export function capturePsmuxPane(target, lines = 5) {
222
+ try {
223
+ const full = psmuxExec(`capture-pane -t ${quoteArg(target)} -p`);
224
+ const nonEmpty = full.split("\n").filter((line) => line.trim() !== "");
225
+ return nonEmpty.slice(-lines).join("\n");
226
+ } catch {
227
+ return "";
228
+ }
229
+ }
230
+
231
+ /**
232
+ * psmux 세션 연결
233
+ * @param {string} sessionName
234
+ */
235
+ export function attachPsmuxSession(sessionName) {
236
+ const result = spawnSync(PSMUX_BIN, ["attach-session", "-t", sessionName], {
237
+ stdio: "inherit",
238
+ timeout: 0,
239
+ windowsHide: false,
240
+ });
241
+ if ((result.status ?? 1) !== 0) {
242
+ throw new Error(`psmux attach 실패 (exit=${result.status})`);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * 세션 attach client 수 조회
248
+ * @param {string} sessionName
249
+ * @returns {number|null}
250
+ */
251
+ export function getPsmuxSessionAttachedCount(sessionName) {
252
+ try {
253
+ const session = parseSessionSummaries(psmuxExec("list-sessions"))
254
+ .find((entry) => entry.sessionName === sessionName);
255
+ return session ? session.attachedCount : null;
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * 팀메이트 조작 키 바인딩 설정
263
+ * @param {string} sessionName
264
+ * @param {object} opts
265
+ * @param {boolean} opts.inProcess
266
+ * @param {string} opts.taskListCommand
267
+ */
268
+ export function configurePsmuxKeybindings(sessionName, opts = {}) {
269
+ const { inProcess = false, taskListCommand = "" } = opts;
270
+ const cond = `#{==:#{session_name},${sessionName}}`;
271
+ const target = `${sessionName}:0`;
272
+ const bindNext = inProcess
273
+ ? `'select-pane -t :.+ \\; resize-pane -Z'`
274
+ : `'select-pane -t :.+'`;
275
+ const bindPrev = inProcess
276
+ ? `'select-pane -t :.- \\; resize-pane -Z'`
277
+ : `'select-pane -t :.-'`;
278
+
279
+ // psmux는 세션별 서버이므로 -t target으로 세션 컨텍스트를 전달해야 한다
280
+ const bindSafe = (cmd) => {
281
+ try { psmuxExec(`-t ${quoteArg(target)} ${cmd}`); } catch { /* 미지원 시 무시 */ }
282
+ };
283
+
284
+ bindSafe(`bind-key -T root -n S-Down if-shell -F '${cond}' ${bindNext} 'send-keys S-Down'`);
285
+ bindSafe(`bind-key -T root -n S-Up if-shell -F '${cond}' ${bindPrev} 'send-keys S-Up'`);
286
+ bindSafe(`bind-key -T root -n S-Right if-shell -F '${cond}' ${bindNext} 'send-keys S-Right'`);
287
+ bindSafe(`bind-key -T root -n S-Left if-shell -F '${cond}' ${bindPrev} 'send-keys S-Left'`);
288
+ bindSafe(`bind-key -T root -n BTab if-shell -F '${cond}' ${bindPrev} 'send-keys BTab'`);
289
+ bindSafe(`bind-key -T root -n Escape if-shell -F '${cond}' 'send-keys C-c' 'send-keys Escape'`);
290
+
291
+ if (taskListCommand) {
292
+ const escaped = taskListCommand.replace(/'/g, "'\\''");
293
+ bindSafe(
294
+ `bind-key -T root -n C-t if-shell -F '${cond}' "display-popup -E '${escaped}'" "send-keys C-t"`
295
+ );
296
+ }
297
+ }