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.
- package/bin/triflux.mjs +557 -251
- package/hooks/keyword-rules.json +16 -0
- package/hub/bridge.mjs +410 -318
- package/hub/hitl.mjs +45 -31
- package/hub/pipe.mjs +457 -0
- package/hub/router.mjs +422 -161
- package/hub/server.mjs +429 -424
- package/hub/store.mjs +388 -314
- package/hub/team/cli-team-common.mjs +348 -0
- package/hub/team/cli-team-control.mjs +393 -0
- package/hub/team/cli-team-start.mjs +512 -0
- package/hub/team/cli-team-status.mjs +269 -0
- package/hub/team/cli.mjs +59 -1459
- package/hub/team/dashboard.mjs +1 -9
- package/hub/team/native.mjs +12 -80
- package/hub/team/nativeProxy.mjs +121 -47
- package/hub/team/pane.mjs +66 -43
- package/hub/team/psmux.mjs +297 -0
- package/hub/team/session.mjs +354 -291
- package/hub/team/shared.mjs +13 -0
- package/hub/team/staleState.mjs +299 -0
- package/hub/tools.mjs +41 -52
- package/hub/workers/claude-worker.mjs +446 -0
- package/hub/workers/codex-mcp.mjs +414 -0
- package/hub/workers/factory.mjs +18 -0
- package/hub/workers/gemini-worker.mjs +349 -0
- package/hub/workers/interface.mjs +41 -0
- package/hud/hud-qos-status.mjs +4 -2
- package/package.json +4 -1
- package/scripts/keyword-detector.mjs +15 -0
- package/scripts/lib/keyword-rules.mjs +4 -1
- package/scripts/psmux-steering-prototype.sh +368 -0
- package/scripts/setup.mjs +128 -70
- package/scripts/tfx-route-worker.mjs +161 -0
- package/scripts/tfx-route.sh +415 -80
- package/skills/tfx-auto/SKILL.md +90 -564
- package/skills/tfx-auto-codex/SKILL.md +1 -3
- package/skills/tfx-codex/SKILL.md +1 -4
- package/skills/tfx-doctor/SKILL.md +1 -0
- package/skills/tfx-gemini/SKILL.md +1 -4
- package/skills/tfx-setup/SKILL.md +1 -4
- package/skills/tfx-team/SKILL.md +53 -62
package/hub/team/dashboard.mjs
CHANGED
|
@@ -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
|
package/hub/team/native.mjs
CHANGED
|
@@ -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
|
-
|
|
47
|
+
성공 → TaskUpdate(status: completed, metadata: {result: "success"}) + SendMessage(to: ${leadName}).
|
|
48
|
+
실패 → TaskUpdate(status: completed, metadata: {result: "failed", error: "에러 요약"}) + SendMessage(to: ${leadName}).
|
|
119
49
|
|
|
120
|
-
|
|
121
|
-
실패
|
|
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
|
-
|
|
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
|
}
|
package/hub/team/nativeProxy.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
9
|
-
function
|
|
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
|
-
/**
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
115
|
-
}
|
|
136
|
+
export function sendKeys(target, keys) {
|
|
137
|
+
muxExec(`send-keys -t ${target} ${keys}`);
|
|
138
|
+
}
|