@triflux/remote 10.0.0-alpha.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/pipe.mjs +579 -0
- package/hub/public/dashboard.html +355 -0
- package/hub/public/tray-icon.ico +0 -0
- package/hub/public/tray-icon.png +0 -0
- package/hub/server.mjs +1124 -0
- package/hub/store-adapter.mjs +851 -0
- package/hub/store.mjs +897 -0
- package/hub/team/agent-map.json +11 -0
- package/hub/team/ansi.mjs +379 -0
- package/hub/team/backend.mjs +90 -0
- package/hub/team/cli/commands/attach.mjs +37 -0
- package/hub/team/cli/commands/control.mjs +43 -0
- package/hub/team/cli/commands/debug.mjs +74 -0
- package/hub/team/cli/commands/focus.mjs +53 -0
- package/hub/team/cli/commands/interrupt.mjs +36 -0
- package/hub/team/cli/commands/kill.mjs +37 -0
- package/hub/team/cli/commands/list.mjs +24 -0
- package/hub/team/cli/commands/send.mjs +37 -0
- package/hub/team/cli/commands/start/index.mjs +106 -0
- package/hub/team/cli/commands/start/parse-args.mjs +130 -0
- package/hub/team/cli/commands/start/start-headless.mjs +109 -0
- package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
- package/hub/team/cli/commands/start/start-mux.mjs +73 -0
- package/hub/team/cli/commands/start/start-wt.mjs +69 -0
- package/hub/team/cli/commands/status.mjs +87 -0
- package/hub/team/cli/commands/stop.mjs +31 -0
- package/hub/team/cli/commands/task.mjs +30 -0
- package/hub/team/cli/commands/tasks.mjs +13 -0
- package/hub/team/cli/help.mjs +42 -0
- package/hub/team/cli/index.mjs +41 -0
- package/hub/team/cli/manifest.mjs +29 -0
- package/hub/team/cli/render.mjs +30 -0
- package/hub/team/cli/services/attach-fallback.mjs +54 -0
- package/hub/team/cli/services/hub-client.mjs +208 -0
- package/hub/team/cli/services/member-selector.mjs +30 -0
- package/hub/team/cli/services/native-control.mjs +117 -0
- package/hub/team/cli/services/runtime-mode.mjs +62 -0
- package/hub/team/cli/services/state-store.mjs +48 -0
- package/hub/team/cli/services/task-model.mjs +30 -0
- package/hub/team/dashboard-anchor.mjs +14 -0
- package/hub/team/dashboard-layout.mjs +33 -0
- package/hub/team/dashboard-open.mjs +153 -0
- package/hub/team/dashboard.mjs +274 -0
- package/hub/team/handoff.mjs +303 -0
- package/hub/team/headless.mjs +1149 -0
- package/hub/team/native-supervisor.mjs +392 -0
- package/hub/team/native.mjs +649 -0
- package/hub/team/nativeProxy.mjs +681 -0
- package/hub/team/orchestrator.mjs +161 -0
- package/hub/team/pane.mjs +153 -0
- package/hub/team/psmux.mjs +1354 -0
- package/hub/team/routing.mjs +223 -0
- package/hub/team/session.mjs +611 -0
- package/hub/team/shared.mjs +13 -0
- package/hub/team/staleState.mjs +361 -0
- package/hub/team/tui-lite.mjs +380 -0
- package/hub/team/tui-viewer.mjs +463 -0
- package/hub/team/tui.mjs +1245 -0
- package/hub/tools.mjs +554 -0
- package/hub/tray.mjs +376 -0
- package/hub/workers/claude-worker.mjs +475 -0
- package/hub/workers/codex-mcp.mjs +504 -0
- package/hub/workers/delegator-mcp.mjs +1076 -0
- package/hub/workers/factory.mjs +21 -0
- package/hub/workers/gemini-worker.mjs +373 -0
- package/hub/workers/interface.mjs +52 -0
- package/hub/workers/worker-utils.mjs +104 -0
- package/package.json +31 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { attachSession, focusPane, focusWtPane } from "../../session.mjs";
|
|
2
|
+
import { DIM, RESET, WHITE } from "../../shared.mjs";
|
|
3
|
+
import { buildManualAttachCommand, launchAttachInWindowsTerminal, wantsWtAttachFallback } from "../services/attach-fallback.mjs";
|
|
4
|
+
import { resolveMember } from "../services/member-selector.mjs";
|
|
5
|
+
import { isNativeMode, isTeamAlive, isWtMode } from "../services/runtime-mode.mjs";
|
|
6
|
+
import { loadTeamState } from "../services/state-store.mjs";
|
|
7
|
+
import { fail, ok, warn } from "../render.mjs";
|
|
8
|
+
|
|
9
|
+
export async function teamFocus(args = []) {
|
|
10
|
+
const state = loadTeamState();
|
|
11
|
+
if (!state || !isTeamAlive(state)) {
|
|
12
|
+
console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (isNativeMode(state)) {
|
|
16
|
+
console.log(`\n ${DIM}in-process 모드는 focus/attach 개념이 없습니다.${RESET}\n ${DIM}직접 지시: tfx multi send <대상> "메시지"${RESET}\n`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const member = resolveMember(state, args[0]);
|
|
21
|
+
if (!member) {
|
|
22
|
+
console.log(`\n 사용법: ${WHITE}tfx multi focus <lead|이름|번호>${RESET}\n`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (isWtMode(state)) {
|
|
27
|
+
const paneIndex = Number(/^wt:(\d+)$/.exec(member.pane || "")?.[1]);
|
|
28
|
+
if (!Number.isFinite(paneIndex)) {
|
|
29
|
+
console.log(`\n ${DIM}wt pane 인덱스 파싱 실패: ${member.pane}${RESET}\n`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (focusWtPane(paneIndex, { layout: state?.wt?.layout || state?.layout || "1xN" })) ok(`${member.name} pane 포커스 이동 (wt)`);
|
|
33
|
+
else warn("wt pane 포커스 이동 실패 (WT_SESSION/wt.exe 상태 확인 필요)");
|
|
34
|
+
console.log("");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
focusPane(member.pane, { zoom: false });
|
|
39
|
+
try {
|
|
40
|
+
attachSession(state.sessionName);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
const allowWt = wantsWtAttachFallback(args);
|
|
43
|
+
if (allowWt && await launchAttachInWindowsTerminal(state.sessionName)) {
|
|
44
|
+
warn(`현재 터미널에서 attach 실패: ${error.message}`);
|
|
45
|
+
ok("Windows Terminal split-pane로 attach 재시도 창을 열었습니다.");
|
|
46
|
+
console.log(` ${DIM}수동 attach 명령: ${buildManualAttachCommand(state.sessionName)}${RESET}\n`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
fail(`attach 실패: ${error.message}`);
|
|
50
|
+
warn(allowWt ? "WT 분할창 attach 자동 검증 실패 (session_attached 증가 없음)" : "자동 WT 분할은 기본 비활성입니다. 필요 시 --wt 옵션으로 실행하세요.");
|
|
51
|
+
console.log(` ${DIM}수동 attach 명령: ${buildManualAttachCommand(state.sessionName)}${RESET}\n`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { sendKeys } from "../../pane.mjs";
|
|
2
|
+
import { DIM, RESET, WHITE, YELLOW } from "../../shared.mjs";
|
|
3
|
+
import { resolveMember } from "../services/member-selector.mjs";
|
|
4
|
+
import { nativeRequest } from "../services/native-control.mjs";
|
|
5
|
+
import { isNativeMode, isTeamAlive, isWtMode } from "../services/runtime-mode.mjs";
|
|
6
|
+
import { loadTeamState } from "../services/state-store.mjs";
|
|
7
|
+
import { ok, warn } from "../render.mjs";
|
|
8
|
+
|
|
9
|
+
export async function teamInterrupt(args = []) {
|
|
10
|
+
const state = loadTeamState();
|
|
11
|
+
if (!state || !isTeamAlive(state)) {
|
|
12
|
+
console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const member = resolveMember(state, args[0] || "lead");
|
|
17
|
+
if (!member) {
|
|
18
|
+
console.log(`\n 사용법: ${WHITE}tfx multi interrupt <lead|이름|번호>${RESET}\n`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (isWtMode(state)) {
|
|
22
|
+
console.log(`\n ${YELLOW}⚠${RESET} wt 모드에서는 pane stdin 주입이 지원되지 않아 interrupt를 자동 전송할 수 없습니다.\n ${DIM}수동으로 해당 pane에서 Ctrl+C를 입력하세요.${RESET}\n`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (isNativeMode(state)) {
|
|
27
|
+
const result = await nativeRequest(state, "/interrupt", { member: member.name });
|
|
28
|
+
(result?.ok ? ok : warn)(`${member.name} ${result?.ok ? "인터럽트 전송" : "인터럽트 실패"}`);
|
|
29
|
+
console.log("");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
sendKeys(member.pane, "C-c");
|
|
34
|
+
ok(`${member.name} 인터럽트 전송`);
|
|
35
|
+
console.log("");
|
|
36
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { closeWtSession, killSession, listSessions } from "../../session.mjs";
|
|
2
|
+
import { DIM, RESET } from "../../shared.mjs";
|
|
3
|
+
import { nativeRequest } from "../services/native-control.mjs";
|
|
4
|
+
import { isNativeMode, isTeamAlive, isWtMode } from "../services/runtime-mode.mjs";
|
|
5
|
+
import { clearTeamState, loadTeamState } from "../services/state-store.mjs";
|
|
6
|
+
import { ok } from "../render.mjs";
|
|
7
|
+
|
|
8
|
+
export async function teamKill() {
|
|
9
|
+
const state = loadTeamState();
|
|
10
|
+
if (state && isNativeMode(state) && isTeamAlive(state)) {
|
|
11
|
+
await nativeRequest(state, "/stop", {});
|
|
12
|
+
try { process.kill(state.native.supervisorPid, "SIGTERM"); } catch {}
|
|
13
|
+
clearTeamState(state.sessionId);
|
|
14
|
+
ok(`종료: ${state.sessionName}`);
|
|
15
|
+
console.log("");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (state && isWtMode(state)) {
|
|
19
|
+
const closed = closeWtSession({ layout: state?.wt?.layout || state?.layout || "1xN", paneCount: state?.wt?.paneCount ?? (state.members || []).length });
|
|
20
|
+
clearTeamState(state.sessionId);
|
|
21
|
+
ok(`종료: ${state.sessionName}${closed ? ` (${closed} panes closed)` : ""}`);
|
|
22
|
+
console.log("");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sessions = listSessions();
|
|
27
|
+
if (!sessions.length) {
|
|
28
|
+
console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const session of sessions) {
|
|
32
|
+
killSession(session);
|
|
33
|
+
ok(`종료: ${session}`);
|
|
34
|
+
}
|
|
35
|
+
clearTeamState(state?.sessionId);
|
|
36
|
+
console.log("");
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AMBER, BOLD, DIM, GREEN, RESET } from "../../shared.mjs";
|
|
2
|
+
import { listSessions } from "../../session.mjs";
|
|
3
|
+
import { isNativeMode, isTeamAlive, isWtMode } from "../services/runtime-mode.mjs";
|
|
4
|
+
import { loadTeamState } from "../services/state-store.mjs";
|
|
5
|
+
|
|
6
|
+
export function teamList() {
|
|
7
|
+
const state = loadTeamState();
|
|
8
|
+
if (state && isTeamAlive(state) && (isNativeMode(state) || isWtMode(state))) {
|
|
9
|
+
console.log(`\n ${AMBER}${BOLD}⬡ 팀 세션 목록${RESET}\n`);
|
|
10
|
+
console.log(` ${GREEN}●${RESET} ${state.sessionName} ${DIM}(${isNativeMode(state) ? "in-process" : "wt"})${RESET}`);
|
|
11
|
+
console.log("");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sessions = listSessions();
|
|
16
|
+
if (!sessions.length) {
|
|
17
|
+
console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log(`\n ${AMBER}${BOLD}⬡ 팀 세션 목록${RESET}\n`);
|
|
22
|
+
for (const session of sessions) console.log(` ${GREEN}●${RESET} ${session}`);
|
|
23
|
+
console.log("");
|
|
24
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { injectPrompt } from "../../pane.mjs";
|
|
2
|
+
import { DIM, RESET, WHITE, YELLOW } from "../../shared.mjs";
|
|
3
|
+
import { resolveMember } from "../services/member-selector.mjs";
|
|
4
|
+
import { nativeRequest } from "../services/native-control.mjs";
|
|
5
|
+
import { isNativeMode, isTeamAlive, isWtMode } from "../services/runtime-mode.mjs";
|
|
6
|
+
import { loadTeamState } from "../services/state-store.mjs";
|
|
7
|
+
import { ok, warn } from "../render.mjs";
|
|
8
|
+
|
|
9
|
+
export async function teamSend(args = []) {
|
|
10
|
+
const state = loadTeamState();
|
|
11
|
+
if (!state || !isTeamAlive(state)) {
|
|
12
|
+
console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const member = resolveMember(state, args[0]);
|
|
17
|
+
const message = args.slice(1).join(" ");
|
|
18
|
+
if (!member || !message) {
|
|
19
|
+
console.log(`\n 사용법: ${WHITE}tfx multi send <lead|이름|번호> "메시지"${RESET}\n`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (isWtMode(state)) {
|
|
23
|
+
console.log(`\n ${YELLOW}⚠${RESET} wt 모드는 pane 프롬프트 자동 주입(send)이 지원되지 않습니다.\n ${DIM}수동 전달: 선택한 pane에 직접 붙여넣으세요.${RESET}\n`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (isNativeMode(state)) {
|
|
28
|
+
const result = await nativeRequest(state, "/send", { member: member.name, text: message });
|
|
29
|
+
(result?.ok ? ok : warn)(`${member.name}${result?.ok ? "에 메시지 주입 완료" : " 메시지 주입 실패"}`);
|
|
30
|
+
console.log("");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
injectPrompt(member.pane, message);
|
|
35
|
+
ok(`${member.name}에 메시지 주입 완료`);
|
|
36
|
+
console.log("");
|
|
37
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { decomposeTask } from "../../../orchestrator.mjs";
|
|
2
|
+
import { hasWindowsTerminal, hasWindowsTerminalSession } from "../../../session.mjs";
|
|
3
|
+
import { AMBER, BOLD, DIM, GREEN, RED, RESET, WHITE } from "../../../shared.mjs";
|
|
4
|
+
import { getDefaultHubUrl, getHubInfo, startHubDaemon } from "../../services/hub-client.mjs";
|
|
5
|
+
import { ensureTmuxOrExit } from "../../services/runtime-mode.mjs";
|
|
6
|
+
import { saveTeamState } from "../../services/state-store.mjs";
|
|
7
|
+
import { fail, ok, warn } from "../../render.mjs";
|
|
8
|
+
import { parseTeamArgs } from "./parse-args.mjs";
|
|
9
|
+
import { startInProcessTeam } from "./start-in-process.mjs";
|
|
10
|
+
import { startMuxTeam } from "./start-mux.mjs";
|
|
11
|
+
import { startHeadlessTeam } from "./start-headless.mjs";
|
|
12
|
+
import { startWtTeam } from "./start-wt.mjs";
|
|
13
|
+
|
|
14
|
+
function printStartUsage() {
|
|
15
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx multi${RESET}\n`);
|
|
16
|
+
console.log(` 사용법: ${WHITE}tfx multi "작업 설명"${RESET}`);
|
|
17
|
+
console.log(` ${WHITE}tfx multi --agents codex,gemini --lead claude "작업"${RESET}`);
|
|
18
|
+
console.log(` ${WHITE}tfx multi --teammate-mode headless "작업"${RESET} ${DIM}(psmux 헤드리스, 기본)${RESET}`);
|
|
19
|
+
console.log(` ${WHITE}tfx multi --dashboard-layout lite "작업"${RESET} ${DIM}(dashboard-lite 기본 뷰)${RESET}`);
|
|
20
|
+
console.log(` ${WHITE}tfx multi --dashboard-layout auto "작업"${RESET} ${DIM}(dashboard viewer 레이아웃 자동)${RESET}`);
|
|
21
|
+
console.log(` ${WHITE}tfx multi --dashboard-anchor window "작업"${RESET} ${DIM}(dashboard anchor: window|tab, 기본 window)${RESET}`);
|
|
22
|
+
console.log(` ${WHITE}tfx multi --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}`);
|
|
23
|
+
console.log(` ${WHITE}tfx multi --teammate-mode in-process "작업"${RESET} ${DIM}(mux 불필요)${RESET}\n`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function printWorkerPreview(agents, subtasks) {
|
|
27
|
+
for (let index = 0; index < subtasks.length; index += 1) {
|
|
28
|
+
const preview = subtasks[index].length > 44 ? `${subtasks[index].slice(0, 44)}…` : subtasks[index];
|
|
29
|
+
console.log(` ${DIM}[${agents[index]}-${index + 1}] ${preview}${RESET}`);
|
|
30
|
+
}
|
|
31
|
+
console.log("");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderTmuxInstallHelp() {
|
|
35
|
+
console.log(`\n ${RED}${BOLD}tmux 미발견${RESET}\n`);
|
|
36
|
+
console.log(" 현재 선택한 모드는 tmux 기반 팀세션이 필요합니다.\n");
|
|
37
|
+
console.log(` 설치:\n WSL2: ${WHITE}wsl sudo apt install tmux${RESET}\n macOS: ${WHITE}brew install tmux${RESET}\n Linux: ${WHITE}apt install tmux${RESET}\n`);
|
|
38
|
+
console.log(` Windows에서는 WSL2를 권장합니다:\n 1. ${WHITE}wsl --install${RESET}\n 2. ${WHITE}wsl sudo apt install tmux${RESET}\n 3. ${WHITE}tfx multi "작업"${RESET}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { parseTeamArgs };
|
|
42
|
+
|
|
43
|
+
export async function teamStart(args = []) {
|
|
44
|
+
const { agents, lead, layout, teammateMode, task: rawTask, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, dashboardLayout, dashboardSize, dashboardAnchor, mcpProfile, model, cwd } = parseTeamArgs(args);
|
|
45
|
+
// --assign 사용 시 task를 자동 생성
|
|
46
|
+
const task = rawTask || (assigns.length > 0 ? assigns.map(a => a.prompt).join(" + ") : "");
|
|
47
|
+
if (!task) return printStartUsage();
|
|
48
|
+
|
|
49
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx multi${RESET}\n`);
|
|
50
|
+
|
|
51
|
+
// P1b: 워커 수 계산 — 단일 워커 headless에는 Hub 불필요
|
|
52
|
+
const workerCount = assigns.length > 0 ? assigns.length : agents.length;
|
|
53
|
+
const needsHub = workerCount >= 2 || teammateMode !== "headless";
|
|
54
|
+
|
|
55
|
+
let hub = null;
|
|
56
|
+
if (needsHub) {
|
|
57
|
+
hub = await getHubInfo();
|
|
58
|
+
if (!hub) {
|
|
59
|
+
process.stdout.write(" Hub 시작 중...");
|
|
60
|
+
try { hub = await startHubDaemon(); } catch (error) { if (error?.code === "HUB_SERVER_MISSING") fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음"); }
|
|
61
|
+
console.log(` ${hub ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`}`);
|
|
62
|
+
if (!hub) warn("Hub 시작 실패 — 수동으로 실행: tfx hub start");
|
|
63
|
+
} else ok(`Hub: ${DIM}${hub.url}${RESET}`);
|
|
64
|
+
} else {
|
|
65
|
+
ok(`Hub: ${DIM}건너뜀 (단일 워커 headless)${RESET}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sessionId = `tfx-multi-${Date.now().toString(36).slice(-4)}${Math.random().toString(36).slice(2, 6)}`;
|
|
69
|
+
const subtasks = decomposeTask(task, agents.length);
|
|
70
|
+
const hubUrl = hub?.url || getDefaultHubUrl();
|
|
71
|
+
let effectiveMode = teammateMode;
|
|
72
|
+
if (effectiveMode === "wt" && !hasWindowsTerminal()) { warn("wt.exe 미발견 — in-process 모드로 자동 fallback"); effectiveMode = "in-process"; }
|
|
73
|
+
if (effectiveMode === "wt" && !hasWindowsTerminalSession()) { warn("WT_SESSION 미감지(Windows Terminal 외부) — in-process 모드로 자동 fallback"); effectiveMode = "in-process"; }
|
|
74
|
+
|
|
75
|
+
console.log(` 세션: ${WHITE}${sessionId}${RESET}`);
|
|
76
|
+
console.log(` 모드: ${effectiveMode}`);
|
|
77
|
+
console.log(` 리드: ${AMBER}${lead}${RESET}`);
|
|
78
|
+
console.log(` 워커: ${agents.map((agent) => `${AMBER}${agent}${RESET}`).join(", ")}`);
|
|
79
|
+
printWorkerPreview(agents, subtasks);
|
|
80
|
+
|
|
81
|
+
if (effectiveMode === "tmux") {
|
|
82
|
+
try { ensureTmuxOrExit(); } catch { return renderTmuxInstallHelp(); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const state = effectiveMode === "in-process"
|
|
86
|
+
? await startInProcessTeam({ sessionId, task, lead, agents, subtasks, hubUrl })
|
|
87
|
+
: effectiveMode === "headless"
|
|
88
|
+
? await startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, dashboardLayout, dashboardSize, dashboardAnchor, mcpProfile, model, cwd })
|
|
89
|
+
: effectiveMode === "wt"
|
|
90
|
+
? await startWtTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl })
|
|
91
|
+
: await startMuxTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl, teammateMode: effectiveMode });
|
|
92
|
+
|
|
93
|
+
if (!state) return fail("in-process supervisor 시작 실패");
|
|
94
|
+
state.sessionId = sessionId;
|
|
95
|
+
saveTeamState(state, sessionId);
|
|
96
|
+
if (typeof state.postSave === "function") state.postSave();
|
|
97
|
+
if (effectiveMode === "in-process") {
|
|
98
|
+
ok("네이티브 in-process 팀 시작 완료");
|
|
99
|
+
console.log(` ${DIM}tmux 없이 실행됨 (직접 CLI 프로세스)${RESET}`);
|
|
100
|
+
console.log(` ${DIM}제어: tfx multi send/control/tasks/status${RESET}\n`);
|
|
101
|
+
} else if (effectiveMode === "wt") {
|
|
102
|
+
ok("Windows Terminal wt 팀 시작 완료");
|
|
103
|
+
console.log(` ${DIM}현재 pane 기준으로 ${state.layout} 분할 생성됨${RESET}`);
|
|
104
|
+
console.log(` ${DIM}wt 모드는 자동 프롬프트 주입/Hub direct 제어(send/control)가 제한됩니다.${RESET}\n`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { normalizeLayout, normalizeTeammateMode } from "../../services/runtime-mode.mjs";
|
|
3
|
+
import { parseDashboardLayout } from "../../../dashboard-layout.mjs";
|
|
4
|
+
import { parseDashboardAnchor } from "../../../dashboard-anchor.mjs";
|
|
5
|
+
|
|
6
|
+
// --assign 파싱 시 마지막 콜론 뒤를 role로 인식할 알려진 역할/CLI 이름
|
|
7
|
+
const KNOWN_ROLES = new Set([
|
|
8
|
+
"codex", "gemini", "claude",
|
|
9
|
+
"executor", "architect", "planner", "analyst", "critic",
|
|
10
|
+
"debugger", "verifier", "code-reviewer", "security-reviewer",
|
|
11
|
+
"test-engineer", "designer", "writer", "scientist",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* --assign "cli:prompt:role" 형식을 콜론-안전하게 파싱한다.
|
|
16
|
+
* 프롬프트 내부의 콜론(:)은 구분자로 취급하지 않는다.
|
|
17
|
+
*
|
|
18
|
+
* 규칙:
|
|
19
|
+
* 1. 첫 번째 콜론 앞 = CLI 이름
|
|
20
|
+
* 2. 마지막 콜론 뒤가 KNOWN_ROLES에 있으면 role, 나머지가 prompt
|
|
21
|
+
* 3. 그 외에는 첫 콜론 뒤 전체가 prompt, role은 빈 문자열
|
|
22
|
+
*/
|
|
23
|
+
function parseAssignValue(raw) {
|
|
24
|
+
const firstColon = raw.indexOf(":");
|
|
25
|
+
if (firstColon < 0) return null;
|
|
26
|
+
|
|
27
|
+
const cli = raw.slice(0, firstColon).trim();
|
|
28
|
+
const rest = raw.slice(firstColon + 1);
|
|
29
|
+
|
|
30
|
+
const lastColon = rest.lastIndexOf(":");
|
|
31
|
+
if (lastColon > 0) {
|
|
32
|
+
const candidate = rest.slice(lastColon + 1).trim().toLowerCase();
|
|
33
|
+
if (KNOWN_ROLES.has(candidate)) {
|
|
34
|
+
return { cli, prompt: rest.slice(0, lastColon).trim(), role: candidate };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { cli, prompt: rest.trim(), role: "" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseTeamArgs(args = []) {
|
|
42
|
+
let agents = ["codex", "gemini"];
|
|
43
|
+
let lead = "claude";
|
|
44
|
+
let layout = "2x2";
|
|
45
|
+
let teammateMode = "auto";
|
|
46
|
+
const taskParts = [];
|
|
47
|
+
const assigns = []; // --assign "codex:프롬프트:역할" 형식
|
|
48
|
+
let autoAttach = true;
|
|
49
|
+
let progressive = true;
|
|
50
|
+
let timeoutSec = 300;
|
|
51
|
+
let verbose = false;
|
|
52
|
+
let dashboard = true;
|
|
53
|
+
let dashboardLayout = "lite";
|
|
54
|
+
let dashboardSize = 0.40;
|
|
55
|
+
let dashboardAnchor = "window";
|
|
56
|
+
let mcpProfile = "";
|
|
57
|
+
let model = "";
|
|
58
|
+
let cwd = "";
|
|
59
|
+
|
|
60
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
61
|
+
const current = args[index];
|
|
62
|
+
if (current === "--agents" && args[index + 1]) {
|
|
63
|
+
agents = args[++index].split(",").map((value) => value.trim().toLowerCase()).filter(Boolean);
|
|
64
|
+
} else if (current === "--lead" && args[index + 1]) {
|
|
65
|
+
lead = args[++index].trim().toLowerCase();
|
|
66
|
+
} else if (current === "--layout" && args[index + 1]) {
|
|
67
|
+
layout = args[++index];
|
|
68
|
+
} else if ((current === "--teammate-mode" || current === "--mode") && args[index + 1]) {
|
|
69
|
+
teammateMode = args[++index];
|
|
70
|
+
} else if (current === "--assign" && args[index + 1]) {
|
|
71
|
+
const parsed = parseAssignValue(args[++index]);
|
|
72
|
+
if (parsed) assigns.push(parsed);
|
|
73
|
+
} else if (current === "--auto-attach") {
|
|
74
|
+
autoAttach = true;
|
|
75
|
+
} else if (current === "--no-auto-attach") {
|
|
76
|
+
autoAttach = false;
|
|
77
|
+
} else if (current === "--verbose") {
|
|
78
|
+
verbose = true;
|
|
79
|
+
} else if (current === "--dashboard") {
|
|
80
|
+
dashboard = true;
|
|
81
|
+
} else if (current === "--no-dashboard") {
|
|
82
|
+
dashboard = false;
|
|
83
|
+
} else if (current === "--dashboard-layout" && args[index + 1]) {
|
|
84
|
+
dashboardLayout = parseDashboardLayout(args[++index]);
|
|
85
|
+
} else if (current === "--dashboard-size" && args[index + 1]) {
|
|
86
|
+
dashboardSize = Math.min(0.8, Math.max(0.2, parseFloat(args[++index]) || 0.50));
|
|
87
|
+
} else if (current === "--dashboard-anchor" && args[index + 1]) {
|
|
88
|
+
dashboardAnchor = parseDashboardAnchor(args[++index]);
|
|
89
|
+
} else if (current === "--no-progressive") {
|
|
90
|
+
progressive = false;
|
|
91
|
+
} else if (current === "--timeout" && args[index + 1]) {
|
|
92
|
+
timeoutSec = Number(args[++index]) || 300;
|
|
93
|
+
} else if (current === "--mcp-profile" && args[index + 1]) {
|
|
94
|
+
mcpProfile = args[++index].trim();
|
|
95
|
+
} else if ((current === "--model" || current === "-m") && args[index + 1]) {
|
|
96
|
+
model = args[++index].trim();
|
|
97
|
+
} else if (current === "--cwd" && args[index + 1]) {
|
|
98
|
+
let p = args[++index].trim();
|
|
99
|
+
// MSYS/Git Bash 드라이브 문자 변환: /c/... → C:/...
|
|
100
|
+
if (process.platform === "win32" && /^\/[a-zA-Z]\//.test(p)) {
|
|
101
|
+
p = p[1].toUpperCase() + ":" + p.slice(2);
|
|
102
|
+
}
|
|
103
|
+
cwd = resolve(p);
|
|
104
|
+
} else if (current.startsWith("-")) {
|
|
105
|
+
console.warn(` ⚠ 미인식 플래그 무시: ${current}`);
|
|
106
|
+
} else {
|
|
107
|
+
taskParts.push(current);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
agents,
|
|
113
|
+
lead,
|
|
114
|
+
layout: normalizeLayout(layout),
|
|
115
|
+
teammateMode: normalizeTeammateMode(teammateMode),
|
|
116
|
+
task: taskParts.join(" ").trim(),
|
|
117
|
+
assigns,
|
|
118
|
+
autoAttach,
|
|
119
|
+
progressive,
|
|
120
|
+
timeoutSec,
|
|
121
|
+
verbose,
|
|
122
|
+
dashboard,
|
|
123
|
+
dashboardLayout,
|
|
124
|
+
dashboardSize,
|
|
125
|
+
dashboardAnchor,
|
|
126
|
+
mcpProfile,
|
|
127
|
+
model,
|
|
128
|
+
cwd,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { BOLD, DIM, GREEN, RESET, AMBER } from "../../../shared.mjs";
|
|
2
|
+
import { runHeadlessInteractive, resolveCliType } from "../../../headless.mjs";
|
|
3
|
+
import { ok, warn } from "../../render.mjs";
|
|
4
|
+
import { buildTasks } from "../../services/task-model.mjs";
|
|
5
|
+
import { clearTeamState } from "../../services/state-store.mjs";
|
|
6
|
+
|
|
7
|
+
export async function startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, dashboardLayout, dashboardSize, dashboardAnchor, mcpProfile, model }) {
|
|
8
|
+
// --assign이 있으면 그것을 사용, 없으면 agents+subtasks 조합
|
|
9
|
+
const assignments = assigns && assigns.length > 0
|
|
10
|
+
? assigns.map((a, i) => ({ cli: resolveCliType(a.cli), prompt: a.prompt, role: a.role || `worker-${i + 1}`, mcp: mcpProfile, model }))
|
|
11
|
+
: subtasks.map((subtask, i) => ({ cli: resolveCliType(agents[i] || agents[0]), prompt: subtask, role: `worker-${i + 1}`, mcp: mcpProfile, model }));
|
|
12
|
+
|
|
13
|
+
const startedAt = Date.now();
|
|
14
|
+
ok(`headless ${assignments.length}워커 시작`);
|
|
15
|
+
|
|
16
|
+
const handle = await runHeadlessInteractive(sessionId, assignments, {
|
|
17
|
+
timeoutSec: timeoutSec || 300,
|
|
18
|
+
layout,
|
|
19
|
+
autoAttach: !!autoAttach,
|
|
20
|
+
dashboard: !!dashboard,
|
|
21
|
+
dashboardLayout,
|
|
22
|
+
dashboardSize: dashboardSize ?? 0.50,
|
|
23
|
+
dashboardAnchor,
|
|
24
|
+
progressive: progressive !== false,
|
|
25
|
+
progressIntervalSec: verbose ? 10 : 0,
|
|
26
|
+
onProgress: verbose ? function onProgress(event) {
|
|
27
|
+
if (event.type === "session_created") {
|
|
28
|
+
console.log(` ${DIM}세션: ${event.sessionName}${RESET}`);
|
|
29
|
+
} else if (event.type === "worker_added") {
|
|
30
|
+
console.log(` ${DIM}[+] ${event.paneTitle}${RESET}`);
|
|
31
|
+
} else if (event.type === "dispatched") {
|
|
32
|
+
console.log(` ${DIM}[${event.paneName}] ${event.cli} dispatch${RESET}`);
|
|
33
|
+
} else if (event.type === "progress") {
|
|
34
|
+
const last = (event.snapshot || "").split("\n").filter(l => l.trim()).pop() || "";
|
|
35
|
+
if (last) console.log(` ${DIM}[${event.paneName}] ${last.slice(0, 60)}${RESET}`);
|
|
36
|
+
} else if (event.type === "completed") {
|
|
37
|
+
const icon = event.matched && event.exitCode === 0 ? `${GREEN}✓${RESET}` : `${AMBER}✗${RESET}`;
|
|
38
|
+
console.log(` ${icon} [${event.paneName}] ${event.cli} exit=${event.exitCode}${event.sessionDead ? " (dead)" : ""}`);
|
|
39
|
+
}
|
|
40
|
+
} : undefined,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// 최소 결과 요약
|
|
44
|
+
const results = handle.results;
|
|
45
|
+
const succeeded = results.filter((r) => r.matched && r.exitCode === 0);
|
|
46
|
+
const failed = results.filter((r) => !r.matched || r.exitCode !== 0);
|
|
47
|
+
|
|
48
|
+
ok(`헤드리스 완료: ${succeeded.length}성공 / ${failed.length}실패 / ${results.length}전체`);
|
|
49
|
+
|
|
50
|
+
if (failed.length > 0) {
|
|
51
|
+
for (const r of failed) console.log(` ${AMBER}✗${RESET} ${r.paneName} (${r.cli}) exit=${r.exitCode}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// handoff 요약 (Lead 토큰 절약 포맷)
|
|
55
|
+
for (const r of results) {
|
|
56
|
+
const icon = r.matched && r.exitCode === 0 ? `${GREEN}✓${RESET}` : `${AMBER}✗${RESET}`;
|
|
57
|
+
if (r.handoffFormatted) {
|
|
58
|
+
const tag = r.handoffFallback ? `${DIM}(fallback)${RESET}` : "";
|
|
59
|
+
console.log(` ${icon} ${r.paneName} ${tag}`);
|
|
60
|
+
for (const line of r.handoffFormatted.split("\n")) {
|
|
61
|
+
console.log(` ${DIM}${line}${RESET}`);
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
if (r.resultFile) console.log(` ${icon} ${r.paneName}: ${r.resultFile}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --verbose: 기존 장황한 출력 (200자 preview)
|
|
69
|
+
if (verbose) {
|
|
70
|
+
for (const r of results) {
|
|
71
|
+
if (r.output) {
|
|
72
|
+
const preview = r.output.length > 200 ? `${r.output.slice(0, 200)}…` : r.output;
|
|
73
|
+
console.log(`\n ${DIM}── ${r.paneName} (${r.cli}${r.role ? `, ${r.role}` : ""}) ──${RESET}`);
|
|
74
|
+
console.log(` ${preview}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// dashboard 모드: tui-viewer가 최종 상태를 렌더링할 시간 확보
|
|
80
|
+
// WT pane spawn (~1s) + node 기동 (~500ms) + 첫 폴링 (~500ms) + 렌더 여유
|
|
81
|
+
if (dashboard) await new Promise(r => setTimeout(r, 5000));
|
|
82
|
+
|
|
83
|
+
// 세션 정리
|
|
84
|
+
handle.kill();
|
|
85
|
+
|
|
86
|
+
const members = [
|
|
87
|
+
{ role: "lead", name: "lead", cli: lead, pane: `${handle.sessionName}:0.0` },
|
|
88
|
+
...results.map((r, i) => ({ role: "worker", name: r.paneName, cli: r.cli, pane: r.paneId || "", subtask: assignments[i]?.prompt })),
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
sessionName: handle.sessionName,
|
|
93
|
+
task,
|
|
94
|
+
lead,
|
|
95
|
+
agents: assignments.map(a => a.cli),
|
|
96
|
+
layout,
|
|
97
|
+
teammateMode: "headless",
|
|
98
|
+
startedAt: Date.now(),
|
|
99
|
+
members,
|
|
100
|
+
headlessResults: results,
|
|
101
|
+
handoffs: results.map((r) => ({ paneName: r.paneName, cli: r.cli, ...r.handoff })),
|
|
102
|
+
tasks: buildTasks(assignments.map(a => a.prompt), members.filter((m) => m.role === "worker")),
|
|
103
|
+
postSave() {
|
|
104
|
+
// headless는 실행 완료 후 즉시 정리 — HUD에 잔존 방지
|
|
105
|
+
clearTeamState(sessionId);
|
|
106
|
+
console.log(`\n ${DIM}세션 정리 완료.${RESET}\n`);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { startNativeSupervisor } from "../../services/native-control.mjs";
|
|
2
|
+
import { buildTasks } from "../../services/task-model.mjs";
|
|
3
|
+
|
|
4
|
+
export async function startInProcessTeam({ sessionId, task, lead, agents, subtasks, hubUrl }) {
|
|
5
|
+
const { runtime, members } = await startNativeSupervisor({
|
|
6
|
+
sessionId,
|
|
7
|
+
task,
|
|
8
|
+
lead,
|
|
9
|
+
agents,
|
|
10
|
+
subtasks,
|
|
11
|
+
hubUrl,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
if (!runtime?.controlUrl) return null;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
sessionName: sessionId,
|
|
18
|
+
task,
|
|
19
|
+
lead,
|
|
20
|
+
agents,
|
|
21
|
+
layout: "native",
|
|
22
|
+
teammateMode: "in-process",
|
|
23
|
+
startedAt: Date.now(),
|
|
24
|
+
hubUrl,
|
|
25
|
+
members: members.map((member, index) => ({
|
|
26
|
+
role: member.role,
|
|
27
|
+
name: member.name,
|
|
28
|
+
cli: member.cli,
|
|
29
|
+
agentId: member.agentId,
|
|
30
|
+
pane: `native:${index}`,
|
|
31
|
+
subtask: member.subtask || null,
|
|
32
|
+
})),
|
|
33
|
+
panes: {},
|
|
34
|
+
tasks: buildTasks(subtasks, members.filter((member) => member.role === "worker")),
|
|
35
|
+
native: {
|
|
36
|
+
controlUrl: runtime.controlUrl,
|
|
37
|
+
supervisorPid: runtime.supervisorPid,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { buildCliCommand, startCliInPane } from "../../../pane.mjs";
|
|
4
|
+
import { orchestrate } from "../../../orchestrator.mjs";
|
|
5
|
+
import { attachSession, configureTeammateKeybindings, createSession } from "../../../session.mjs";
|
|
6
|
+
import { BOLD, DIM, GREEN, RESET } from "../../../shared.mjs";
|
|
7
|
+
import { toAgentId } from "../../services/member-selector.mjs";
|
|
8
|
+
import { PKG_ROOT, TEAM_PROFILE } from "../../services/state-store.mjs";
|
|
9
|
+
import { buildTasks } from "../../services/task-model.mjs";
|
|
10
|
+
import { ok, warn } from "../../render.mjs";
|
|
11
|
+
|
|
12
|
+
export async function startMuxTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl, teammateMode }) {
|
|
13
|
+
const paneCount = agents.length + 1;
|
|
14
|
+
const effectiveLayout = paneCount <= 4 ? layout : (layout === "Nx1" ? "Nx1" : "1xN");
|
|
15
|
+
console.log(` 레이아웃: ${effectiveLayout} (${paneCount} panes)`);
|
|
16
|
+
|
|
17
|
+
const session = createSession(sessionId, { layout: effectiveLayout, paneCount });
|
|
18
|
+
const leadTarget = session.panes[0];
|
|
19
|
+
startCliInPane(leadTarget, buildCliCommand(lead));
|
|
20
|
+
|
|
21
|
+
const members = [{ role: "lead", name: "lead", cli: lead, pane: leadTarget, agentId: toAgentId(lead, leadTarget) }];
|
|
22
|
+
const assignments = [];
|
|
23
|
+
for (let index = 0; index < agents.length; index += 1) {
|
|
24
|
+
const cli = agents[index];
|
|
25
|
+
const pane = session.panes[index + 1];
|
|
26
|
+
startCliInPane(pane, buildCliCommand(cli));
|
|
27
|
+
const worker = { role: "worker", name: `${cli}-${index + 1}`, cli, pane, subtask: subtasks[index], agentId: toAgentId(cli, pane) };
|
|
28
|
+
members.push(worker);
|
|
29
|
+
assignments.push({ target: pane, cli, subtask: subtasks[index] });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ok("CLI 초기화 대기 (3초)...");
|
|
33
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
34
|
+
await orchestrate(sessionId, assignments, { hubUrl, teammateMode, lead: { target: leadTarget, cli: lead, task } });
|
|
35
|
+
ok("리드/워커 프롬프트 주입 완료");
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
sessionName: sessionId,
|
|
39
|
+
task,
|
|
40
|
+
lead,
|
|
41
|
+
agents,
|
|
42
|
+
layout: effectiveLayout,
|
|
43
|
+
teammateMode,
|
|
44
|
+
startedAt: Date.now(),
|
|
45
|
+
hubUrl,
|
|
46
|
+
members,
|
|
47
|
+
panes: Object.fromEntries(members.map((member) => [member.pane, {
|
|
48
|
+
role: member.role,
|
|
49
|
+
name: member.name,
|
|
50
|
+
cli: member.cli,
|
|
51
|
+
agentId: member.agentId,
|
|
52
|
+
subtask: member.subtask || null,
|
|
53
|
+
}])),
|
|
54
|
+
tasks: buildTasks(subtasks, members.filter((member) => member.role === "worker")),
|
|
55
|
+
postSave() {
|
|
56
|
+
const profilePrefix = TEAM_PROFILE === "team" ? "" : `TFX_TEAM_PROFILE=${TEAM_PROFILE} `;
|
|
57
|
+
const taskListCommand = `${profilePrefix}${process.execPath} ${join(PKG_ROOT, "bin", "triflux.mjs")} team tasks`;
|
|
58
|
+
configureTeammateKeybindings(sessionId, { inProcess: false, taskListCommand });
|
|
59
|
+
console.log(`\n ${GREEN}${BOLD}팀 세션 준비 완료${RESET}`);
|
|
60
|
+
console.log(` ${DIM}Shift+Down: 다음 팀메이트 전환${RESET}`);
|
|
61
|
+
console.log(` ${DIM}Shift+Tab / Shift+Left: 이전 팀메이트 전환${RESET}`);
|
|
62
|
+
console.log(` ${DIM}Escape: 현재 팀메이트 인터럽트${RESET}`);
|
|
63
|
+
console.log(` ${DIM}Ctrl+T: 태스크 목록${RESET}`);
|
|
64
|
+
console.log(` ${DIM}참고: Shift+Up은 Claude Code 미지원 (scroll-up 충돌). Shift+Tab 사용${RESET}`);
|
|
65
|
+
console.log(` ${DIM}Ctrl+B → D: 세션 분리 (백그라운드)${RESET}\n`);
|
|
66
|
+
if (process.stdout.isTTY && process.stdin.isTTY) attachSession(sessionId);
|
|
67
|
+
else {
|
|
68
|
+
warn("TTY 미지원 환경이라 자동 attach를 생략함");
|
|
69
|
+
console.log(` ${DIM}수동 연결: tfx multi attach${RESET}\n`);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|