triflux 3.2.0-dev.7 → 3.2.0-dev.9

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 (42) hide show
  1. package/bin/triflux.mjs +557 -251
  2. package/hooks/keyword-rules.json +16 -0
  3. package/hub/bridge.mjs +410 -318
  4. package/hub/hitl.mjs +45 -31
  5. package/hub/pipe.mjs +457 -0
  6. package/hub/router.mjs +422 -161
  7. package/hub/server.mjs +429 -424
  8. package/hub/store.mjs +388 -314
  9. package/hub/team/cli-team-common.mjs +348 -0
  10. package/hub/team/cli-team-control.mjs +393 -0
  11. package/hub/team/cli-team-start.mjs +512 -0
  12. package/hub/team/cli-team-status.mjs +269 -0
  13. package/hub/team/cli.mjs +59 -1459
  14. package/hub/team/dashboard.mjs +1 -9
  15. package/hub/team/native.mjs +12 -80
  16. package/hub/team/nativeProxy.mjs +121 -47
  17. package/hub/team/pane.mjs +66 -43
  18. package/hub/team/psmux.mjs +297 -0
  19. package/hub/team/session.mjs +354 -291
  20. package/hub/team/shared.mjs +13 -0
  21. package/hub/team/staleState.mjs +299 -0
  22. package/hub/tools.mjs +41 -52
  23. package/hub/workers/claude-worker.mjs +446 -0
  24. package/hub/workers/codex-mcp.mjs +414 -0
  25. package/hub/workers/factory.mjs +18 -0
  26. package/hub/workers/gemini-worker.mjs +349 -0
  27. package/hub/workers/interface.mjs +41 -0
  28. package/hud/hud-qos-status.mjs +4 -2
  29. package/package.json +4 -1
  30. package/scripts/keyword-detector.mjs +15 -0
  31. package/scripts/lib/keyword-rules.mjs +4 -1
  32. package/scripts/psmux-steering-prototype.sh +368 -0
  33. package/scripts/setup.mjs +128 -70
  34. package/scripts/tfx-route-worker.mjs +161 -0
  35. package/scripts/tfx-route.sh +415 -80
  36. package/skills/tfx-auto/SKILL.md +90 -564
  37. package/skills/tfx-auto-codex/SKILL.md +1 -3
  38. package/skills/tfx-codex/SKILL.md +1 -4
  39. package/skills/tfx-doctor/SKILL.md +1 -0
  40. package/skills/tfx-gemini/SKILL.md +1 -4
  41. package/skills/tfx-setup/SKILL.md +1 -4
  42. package/skills/tfx-team/SKILL.md +53 -62
@@ -6,15 +6,7 @@
6
6
  // node hub/team/dashboard.mjs --session <세션이름> [--interval 2]
7
7
  // node hub/team/dashboard.mjs --team <팀이름> [--interval 2]
8
8
  import { get } from "node:http";
9
-
10
- // ── 색상 ──
11
- const AMBER = "\x1b[38;5;214m";
12
- const GREEN = "\x1b[38;5;82m";
13
- const RED = "\x1b[38;5;196m";
14
- const GRAY = "\x1b[38;5;245m";
15
- const DIM = "\x1b[2m";
16
- const BOLD = "\x1b[1m";
17
- const RESET = "\x1b[0m";
9
+ import { AMBER, GREEN, RED, GRAY, DIM, BOLD, RESET } from "./shared.mjs";
18
10
 
19
11
  /**
20
12
  * HTTP GET JSON
@@ -8,81 +8,6 @@
8
8
 
9
9
  const ROUTE_SCRIPT = "~/.claude/scripts/tfx-route.sh";
10
10
 
11
- /**
12
- * CLI 타입별 teammate 프롬프트 생성
13
- * @param {'codex'|'gemini'|'claude'} cli — CLI 타입
14
- * @param {object} opts
15
- * @param {string} opts.subtask — 서브태스크 설명
16
- * @param {string} [opts.role] — 역할 (executor, designer, reviewer 등)
17
- * @param {string} [opts.teamName] — 팀 이름
18
- * @returns {string} teammate 프롬프트
19
- */
20
- export function buildTeammatePrompt(cli, opts = {}) {
21
- const { subtask, role = "executor", teamName = "tfx-team" } = opts;
22
-
23
- if (cli === "claude") {
24
- return `너는 ${teamName}의 Claude 워커이다.
25
-
26
- [작업] ${subtask}
27
-
28
- [실행]
29
- 1. TaskList에서 pending 작업을 확인하고 claim (TaskUpdate: in_progress)
30
- 2. Glob, Grep, Read, Bash 등 도구로 직접 수행
31
- 3. 완료 시 TaskUpdate(status: completed) + SendMessage로 리드에게 보고
32
- 4. 추가 작업이 있으면 반복
33
-
34
- 에러 시 TaskUpdate(status: failed) + SendMessage로 보고.`;
35
- }
36
-
37
- const label = cli === "codex" ? "Codex" : "Gemini";
38
- const escaped = subtask.replace(/'/g, "'\\''");
39
-
40
- return `너는 ${teamName}의 ${label} 워커이다.
41
-
42
- [작업] ${subtask}
43
-
44
- [실행]
45
- 1. TaskList에서 pending 작업을 확인하고 claim (TaskUpdate: in_progress)
46
- 2. Bash("bash ${ROUTE_SCRIPT} ${role} '${escaped}' auto")로 실행
47
- 3. 결과 확인 후 TaskUpdate(status: completed) + SendMessage로 리드에게 보고
48
- 4. 추가 pending 작업이 있으면 반복
49
-
50
- [규칙]
51
- - 실제 구현은 ${label} CLI가 수행 — 너는 실행+보고 역할
52
- - 에러 시 TaskUpdate(status: failed) + SendMessage로 보고`;
53
- }
54
-
55
- /**
56
- * teammate 이름 생성
57
- * @param {'codex'|'gemini'|'claude'} cli
58
- * @param {number} index — 0-based
59
- * @returns {string}
60
- */
61
- export function buildTeammateName(cli, index) {
62
- return `${cli}-worker-${index + 1}`;
63
- }
64
-
65
- /**
66
- * 트리아지 결과에서 팀 멤버 설정 생성
67
- * @param {string} teamName — 팀 이름
68
- * @param {Array<{cli: string, subtask: string, role?: string}>} assignments
69
- * @returns {{ name: string, members: Array<{name: string, cli: string, prompt: string}> }}
70
- */
71
- export function buildTeamConfig(teamName, assignments) {
72
- return {
73
- name: teamName,
74
- members: assignments.map((a, i) => ({
75
- name: buildTeammateName(a.cli, i),
76
- cli: a.cli,
77
- prompt: buildTeammatePrompt(a.cli, {
78
- subtask: a.subtask,
79
- role: a.role || "executor",
80
- teamName,
81
- }),
82
- })),
83
- };
84
- }
85
-
86
11
  /**
87
12
  * v2.2 슬림 래퍼 프롬프트 생성
88
13
  * Agent spawn으로 네비게이션에 등록하되, 실제 작업은 tfx-route.sh가 수행.
@@ -113,12 +38,17 @@ export function buildSlimWrapperPrompt(cli, opts = {}) {
113
38
  // 셸 이스케이프
114
39
  const escaped = subtask.replace(/'/g, "'\\''");
115
40
 
116
- return `Bash 1회 실행 후 종료.
41
+ return `Bash 1회 실행 후 반드시 종료하라. 어떤 경우에도 hang하지 마라.
42
+ gemini/codex를 직접 호출하지 마라. 반드시 tfx-route.sh를 거쳐야 한다.
43
+ 프롬프트를 파일로 저장하지 마라. tfx-route.sh가 인자로 받는다.
44
+
45
+ TFX_TEAM_NAME="${teamName}" TFX_TEAM_TASK_ID="${taskId}" TFX_TEAM_AGENT_NAME="${agentName}" TFX_TEAM_LEAD_NAME="${leadName}" bash ${ROUTE_SCRIPT} "${role}" '${escaped}' ${mcp_profile}
117
46
 
118
- TFX_TEAM_NAME=${teamName} TFX_TEAM_TASK_ID=${taskId} TFX_TEAM_AGENT_NAME=${agentName} TFX_TEAM_LEAD_NAME=${leadName} bash ${ROUTE_SCRIPT} ${role} '${escaped}' ${mcp_profile}
47
+ 성공 TaskUpdate(status: completed, metadata: {result: "success"}) + SendMessage(to: ${leadName}).
48
+ 실패 → TaskUpdate(status: completed, metadata: {result: "failed", error: "에러 요약"}) + SendMessage(to: ${leadName}).
119
49
 
120
- 완료 TaskUpdate(status: completed) + SendMessage(to: ${leadName}).
121
- 실패 TaskUpdate(status: failed) + SendMessage(to: ${leadName}).`;
50
+ 중요: TaskUpdate의 status "completed"만 사용. "failed"는 API 미지원.
51
+ 실패 여부는 metadata.result로 구분. Bash 실패 시에도 반드시 TaskUpdate + SendMessage 후 종료.`;
122
52
  }
123
53
 
124
54
  /**
@@ -126,5 +56,7 @@ TFX_TEAM_NAME=${teamName} TFX_TEAM_TASK_ID=${taskId} TFX_TEAM_AGENT_NAME=${agent
126
56
  * @returns {string}
127
57
  */
128
58
  export function generateTeamName() {
129
- return `tfx-${Date.now().toString(36).slice(-6)}`;
59
+ const ts = Date.now().toString(36).slice(-4);
60
+ const rand = Math.random().toString(36).slice(2, 6);
61
+ return `tfx-${ts}${rand}`;
130
62
  }
@@ -22,6 +22,14 @@ const CLAUDE_HOME = join(homedir(), '.claude');
22
22
  const TEAMS_ROOT = join(CLAUDE_HOME, 'teams');
23
23
  const TASKS_ROOT = join(CLAUDE_HOME, 'tasks');
24
24
 
25
+ // ── 인메모리 캐시 (디렉토리 mtime 기반 무효화) ──
26
+ const _dirCache = new Map(); // tasksDir → { mtimeMs, files: string[] }
27
+ const _taskIdIndex = new Map(); // taskId → filePath
28
+
29
+ function _invalidateCache(tasksDir) {
30
+ _dirCache.delete(tasksDir);
31
+ }
32
+
25
33
  function err(code, message, extra = {}) {
26
34
  return { ok: false, error: { code, message, ...extra } };
27
35
  }
@@ -44,13 +52,24 @@ function atomicWriteJson(path, value) {
44
52
  mkdirSync(dirname(path), { recursive: true });
45
53
  const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
46
54
  writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
47
- renameSync(tmp, path);
55
+ try {
56
+ renameSync(tmp, path);
57
+ } catch (e) {
58
+ // Windows NTFS: 대상 파일 존재 시 rename 실패 가능 → 삭제 후 재시도
59
+ if (process.platform === 'win32' && (e.code === 'EPERM' || e.code === 'EEXIST')) {
60
+ try { unlinkSync(path); } catch {}
61
+ renameSync(tmp, path);
62
+ } else {
63
+ try { unlinkSync(tmp); } catch {}
64
+ throw e;
65
+ }
66
+ }
48
67
  }
49
68
 
50
- function sleepMs(ms) {
51
- // busy-wait를 피하고 Atomics.wait로 동기 대기 (CPU 점유 최소화)
52
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
53
- }
69
+ function sleepMs(ms) {
70
+ // busy-wait를 피하고 Atomics.wait로 동기 대기 (CPU 점유 최소화)
71
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
72
+ }
54
73
 
55
74
  function withFileLock(lockPath, fn, retries = 20, delayMs = 25) {
56
75
  let fd = null;
@@ -114,22 +133,46 @@ export function resolveTeamPaths(teamName) {
114
133
 
115
134
  function collectTaskFiles(tasksDir) {
116
135
  if (!existsSync(tasksDir)) return [];
117
- return readdirSync(tasksDir)
136
+
137
+ // 디렉토리 mtime 기반 캐시 — O(N) I/O를 반복 호출 시 O(1)로 축소
138
+ let dirMtime;
139
+ try { dirMtime = statSync(tasksDir).mtimeMs; } catch { return []; }
140
+
141
+ const cached = _dirCache.get(tasksDir);
142
+ if (cached && cached.mtimeMs === dirMtime) {
143
+ return cached.files;
144
+ }
145
+
146
+ const files = readdirSync(tasksDir)
118
147
  .filter((name) => name.endsWith('.json'))
119
148
  .filter((name) => !name.endsWith('.lock'))
120
149
  .filter((name) => name !== '.highwatermark')
121
150
  .map((name) => join(tasksDir, name));
151
+
152
+ _dirCache.set(tasksDir, { mtimeMs: dirMtime, files });
153
+ return files;
122
154
  }
123
155
 
124
156
  function locateTaskFile(tasksDir, taskId) {
125
157
  const direct = join(tasksDir, `${taskId}.json`);
126
158
  if (existsSync(direct)) return direct;
127
159
 
160
+ // ID→파일 인덱스 캐시
161
+ const indexed = _taskIdIndex.get(taskId);
162
+ if (indexed && existsSync(indexed)) return indexed;
163
+
164
+ // 캐시된 collectTaskFiles로 풀 스캔
128
165
  const files = collectTaskFiles(tasksDir);
129
166
  for (const file of files) {
130
- if (basename(file, '.json') === taskId) return file;
167
+ if (basename(file, '.json') === taskId) {
168
+ _taskIdIndex.set(taskId, file);
169
+ return file;
170
+ }
131
171
  const json = readJsonSafe(file);
132
- if (json && String(json.id || '') === taskId) return file;
172
+ if (json && String(json.id || '') === taskId) {
173
+ _taskIdIndex.set(taskId, file);
174
+ return file;
175
+ }
133
176
  }
134
177
  return null;
135
178
  }
@@ -242,7 +285,21 @@ export function teamTaskList(args = {}) {
242
285
  };
243
286
  }
244
287
 
288
+ // status 화이트리스트 (Claude Code API 호환)
289
+ const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
290
+
245
291
  export function teamTaskUpdate(args = {}) {
292
+ // "failed" → "completed" + metadata.result 자동 매핑
293
+ if (String(args.status || '') === 'failed') {
294
+ args = {
295
+ ...args,
296
+ status: 'completed',
297
+ metadata_patch: { ...(args.metadata_patch || {}), result: 'failed' },
298
+ };
299
+ } else if (args.status != null && !VALID_STATUSES.has(String(args.status))) {
300
+ return err('INVALID_STATUS', `유효하지 않은 status: ${args.status}. 허용: ${[...VALID_STATUSES].join(', ')}`);
301
+ }
302
+
246
303
  const {
247
304
  team_name,
248
305
  task_id,
@@ -369,6 +426,7 @@ export function teamTaskUpdate(args = {}) {
369
426
 
370
427
  if (updated) {
371
428
  atomicWriteJson(taskFile, after);
429
+ _invalidateCache(dirname(taskFile));
372
430
  }
373
431
 
374
432
  let afterMtime = beforeMtime;
@@ -419,42 +477,58 @@ export function teamSendMessage(args = {}) {
419
477
  return err('TEAM_NOT_FOUND', `팀 디렉토리가 없습니다: ${paths.team_dir}`);
420
478
  }
421
479
 
422
- const recipient = sanitizeRecipientName(to);
423
- const inboxFile = join(paths.inboxes_dir, `${recipient}.json`);
424
- const lockFile = `${inboxFile}.lock`;
425
- let message;
426
-
427
- try {
428
- const unreadCount = withFileLock(lockFile, () => {
429
- const queue = readJsonSafe(inboxFile);
430
- const list = Array.isArray(queue) ? queue : [];
431
-
432
- message = {
433
- id: randomUUID(),
434
- from: String(from),
435
- text: String(text),
436
- ...(summary ? { summary: String(summary) } : {}),
437
- timestamp: new Date().toISOString(),
438
- color: String(color || 'blue'),
439
- read: false,
440
- };
441
- list.push(message);
442
- atomicWriteJson(inboxFile, list);
443
-
444
- return list.filter((m) => m?.read !== true).length;
445
- });
446
-
447
- return {
448
- ok: true,
449
- data: {
450
- message_id: message.id,
451
- recipient,
452
- inbox_file: inboxFile,
453
- queued_at: message.timestamp,
454
- unread_count: unreadCount,
455
- },
456
- };
457
- } catch (e) {
458
- return err('SEND_MESSAGE_FAILED', e.message);
459
- }
460
- }
480
+ const recipient = sanitizeRecipientName(to);
481
+ const inboxFile = join(paths.inboxes_dir, `${recipient}.json`);
482
+ const lockFile = `${inboxFile}.lock`;
483
+ let message;
484
+
485
+ try {
486
+ const unreadCount = withFileLock(lockFile, () => {
487
+ const queue = readJsonSafe(inboxFile);
488
+ const list = Array.isArray(queue) ? queue : [];
489
+
490
+ message = {
491
+ id: randomUUID(),
492
+ from: String(from),
493
+ text: String(text),
494
+ ...(summary ? { summary: String(summary) } : {}),
495
+ timestamp: new Date().toISOString(),
496
+ color: String(color || 'blue'),
497
+ read: false,
498
+ };
499
+ list.push(message);
500
+
501
+ // inbox 정리: 최대 200개 유지, read + 1시간 경과 메시지 제거
502
+ const MAX_INBOX = 200;
503
+ if (list.length > MAX_INBOX) {
504
+ const ONE_HOUR_MS = 3600000;
505
+ const cutoff = Date.now() - ONE_HOUR_MS;
506
+ const pruned = list.filter((m) =>
507
+ m?.read !== true || !m?.timestamp || new Date(m.timestamp).getTime() > cutoff
508
+ );
509
+ list.length = 0;
510
+ list.push(...pruned);
511
+ if (list.length > MAX_INBOX) {
512
+ list.splice(0, list.length - MAX_INBOX);
513
+ }
514
+ }
515
+
516
+ atomicWriteJson(inboxFile, list);
517
+
518
+ return list.filter((m) => m?.read !== true).length;
519
+ });
520
+
521
+ return {
522
+ ok: true,
523
+ data: {
524
+ message_id: message.id,
525
+ recipient,
526
+ inbox_file: inboxFile,
527
+ queued_at: message.timestamp,
528
+ unread_count: unreadCount,
529
+ },
530
+ };
531
+ } catch (e) {
532
+ return err('SEND_MESSAGE_FAILED', e.message);
533
+ }
534
+ }
package/hub/team/pane.mjs CHANGED
@@ -1,21 +1,34 @@
1
1
  // hub/team/pane.mjs — pane별 CLI 실행 + stdin 주입
2
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
-
8
- /** Windows 경로를 MSYS2/Git Bash tmux용 POSIX 경로로 변환 */
9
- function toTmuxPath(p) {
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) {
10
19
  if (process.platform !== "win32") return p;
11
20
 
21
+ const mux = detectMultiplexer();
22
+
23
+ // psmux는 Windows 네이티브 경로 그대로 사용
24
+ if (mux === "psmux") return p;
25
+
12
26
  const normalized = p.replace(/\\/g, "/");
13
27
  const m = normalized.match(/^([A-Za-z]):\/(.*)$/);
14
28
  if (!m) return normalized;
15
29
 
16
30
  const drive = m[1].toLowerCase();
17
31
  const rest = m[2];
18
- const mux = detectMultiplexer();
19
32
 
20
33
  // wsl tmux는 /mnt/c/... 경로를 사용
21
34
  if (mux === "wsl-tmux") {
@@ -26,12 +39,13 @@ function toTmuxPath(p) {
26
39
  return `/${drive}/${rest}`;
27
40
  }
28
41
 
29
- /** tmux 커맨드 실행 (session.mjs와 동일 패턴) */
30
- function tmux(args, opts = {}) {
31
- return tmuxExec(args, {
32
- encoding: "utf8",
33
- timeout: 10000,
34
- stdio: ["pipe", "pipe", "pipe"],
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"],
35
49
  ...opts,
36
50
  });
37
51
  }
@@ -39,18 +53,18 @@ function tmux(args, opts = {}) {
39
53
  /**
40
54
  * CLI 에이전트 시작 커맨드 생성
41
55
  * @param {'codex'|'gemini'|'claude'} cli
42
- * @param {{ trustMode?: boolean }} [options]
43
- * @returns {string} 실행할 셸 커맨드
44
- */
45
- export function buildCliCommand(cli, options = {}) {
46
- const { trustMode = false } = options;
47
-
48
- switch (cli) {
49
- case "codex":
50
- // trust 모드에서는 승인/샌드박스 우회 + alt-screen 비활성화
51
- return trustMode
52
- ? "codex --dangerously-bypass-approvals-and-sandbox --no-alt-screen"
53
- : "codex";
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";
54
68
  case "gemini":
55
69
  // interactive 모드 — MCP는 ~/.gemini/settings.json에 사전 등록
56
70
  return "gemini";
@@ -63,15 +77,14 @@ export function buildCliCommand(cli, options = {}) {
63
77
  }
64
78
 
65
79
  /**
66
- * tmux pane에 CLI 시작
80
+ * pane에 CLI 시작
67
81
  * @param {string} target — 예: tfx-team-abc:0.1
68
82
  * @param {string} command — 실행할 커맨드
69
83
  */
70
- export function startCliInPane(target, command) {
71
- // 특수문자 이스케이프: 작은따옴표 내부에서 안전하도록
72
- const escaped = command.replace(/'/g, "'\\''");
73
- tmux(`send-keys -t ${target} '${escaped}' Enter`);
74
- }
84
+ export function startCliInPane(target, command) {
85
+ // CLI 시작도 buffer paste를 재사용해 셸/플랫폼별 quoting 차이를 제거한다.
86
+ injectPrompt(target, command);
87
+ }
75
88
 
76
89
  /**
77
90
  * pane에 프롬프트 주입 (load-buffer + paste-buffer 방식)
@@ -88,14 +101,24 @@ export function injectPrompt(target, prompt) {
88
101
  const safeTarget = target.replace(/[:.]/g, "-");
89
102
  const tmpFile = join(tmpDir, `prompt-${safeTarget}-${Date.now()}.txt`);
90
103
 
91
- try {
92
- writeFileSync(tmpFile, prompt, "utf8");
93
-
94
- // tmux load-buffer paste-buffer Enter (Windows 경로 변환 필요)
95
- tmux(`load-buffer ${toTmuxPath(tmpFile)}`);
96
- tmux(`paste-buffer -t ${target}`);
97
- tmux(`send-keys -t ${target} Enter`);
98
- } finally {
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 {
99
122
  // 임시 파일 정리
100
123
  try {
101
124
  unlinkSync(tmpFile);
@@ -110,6 +133,6 @@ export function injectPrompt(target, prompt) {
110
133
  * @param {string} target — 예: tfx-team-abc:0.1
111
134
  * @param {string} keys — tmux 키 표현 (예: 'C-c', 'Enter')
112
135
  */
113
- export function sendKeys(target, keys) {
114
- tmux(`send-keys -t ${target} ${keys}`);
115
- }
136
+ export function sendKeys(target, keys) {
137
+ muxExec(`send-keys -t ${target} ${keys}`);
138
+ }