@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,69 @@
|
|
|
1
|
+
import { createWtSession } from "../../../session.mjs";
|
|
2
|
+
import { buildCliCommand } from "../../../pane.mjs";
|
|
3
|
+
import { toAgentId } from "../../services/member-selector.mjs";
|
|
4
|
+
import { buildTasks } from "../../services/task-model.mjs";
|
|
5
|
+
import { warn } from "../../render.mjs";
|
|
6
|
+
|
|
7
|
+
export async function startWtTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl }) {
|
|
8
|
+
const paneCount = agents.length + 1;
|
|
9
|
+
const effectiveLayout = layout === "Nx1" ? "Nx1" : "1xN";
|
|
10
|
+
if (layout !== effectiveLayout) warn(`wt 모드에서 ${layout} 레이아웃은 미지원 — ${effectiveLayout}로 대체`);
|
|
11
|
+
console.log(` 레이아웃: ${effectiveLayout} (${paneCount} panes)`);
|
|
12
|
+
|
|
13
|
+
const session = createWtSession(sessionId, {
|
|
14
|
+
layout: effectiveLayout,
|
|
15
|
+
paneCommands: [
|
|
16
|
+
{ title: `${sessionId}-lead`, command: buildCliCommand(lead) },
|
|
17
|
+
...agents.map((cli, index) => ({
|
|
18
|
+
title: `${sessionId}-${cli}-${index + 1}`,
|
|
19
|
+
command: buildCliCommand(cli),
|
|
20
|
+
})),
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const members = [
|
|
25
|
+
{
|
|
26
|
+
role: "lead",
|
|
27
|
+
name: "lead",
|
|
28
|
+
cli: lead,
|
|
29
|
+
pane: session.panes[0] || "wt:0",
|
|
30
|
+
agentId: toAgentId(lead, session.panes[0] || "wt:0"),
|
|
31
|
+
},
|
|
32
|
+
...agents.map((cli, index) => {
|
|
33
|
+
const pane = session.panes[index + 1] || `wt:${index + 1}`;
|
|
34
|
+
return {
|
|
35
|
+
role: "worker",
|
|
36
|
+
name: `${cli}-${index + 1}`,
|
|
37
|
+
cli,
|
|
38
|
+
pane,
|
|
39
|
+
subtask: subtasks[index],
|
|
40
|
+
agentId: toAgentId(cli, pane),
|
|
41
|
+
};
|
|
42
|
+
}),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
sessionName: sessionId,
|
|
47
|
+
task,
|
|
48
|
+
lead,
|
|
49
|
+
agents,
|
|
50
|
+
layout: effectiveLayout,
|
|
51
|
+
teammateMode: "wt",
|
|
52
|
+
startedAt: Date.now(),
|
|
53
|
+
hubUrl,
|
|
54
|
+
members,
|
|
55
|
+
panes: Object.fromEntries(members.map((member) => [member.pane, {
|
|
56
|
+
role: member.role,
|
|
57
|
+
name: member.name,
|
|
58
|
+
cli: member.cli,
|
|
59
|
+
agentId: member.agentId,
|
|
60
|
+
subtask: member.subtask || null,
|
|
61
|
+
}])),
|
|
62
|
+
tasks: buildTasks(subtasks, members.filter((member) => member.role === "worker")),
|
|
63
|
+
wt: {
|
|
64
|
+
windowId: 0,
|
|
65
|
+
layout: effectiveLayout,
|
|
66
|
+
paneCount: session.paneCount,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { AMBER, BOLD, DIM, GRAY, GREEN, RED, RESET } from "../../shared.mjs";
|
|
2
|
+
import { hasWindowsTerminalSession } from "../../session.mjs";
|
|
3
|
+
import { fetchHubTaskList, nativeGetStatus } from "../services/hub-client.mjs";
|
|
4
|
+
import { isNativeMode, isTeamAlive, isWtMode } from "../services/runtime-mode.mjs";
|
|
5
|
+
import { loadTeamState } from "../services/state-store.mjs";
|
|
6
|
+
import { formatCompletionSuffix } from "../render.mjs";
|
|
7
|
+
|
|
8
|
+
export async function teamStatus(args = []) {
|
|
9
|
+
const json = process.env.TFX_OUTPUT_JSON === "1" || args.includes("--json");
|
|
10
|
+
const state = loadTeamState();
|
|
11
|
+
if (!state) {
|
|
12
|
+
if (json) {
|
|
13
|
+
process.stdout.write(`${JSON.stringify({ status: "offline", sessionName: null, alive: false }, null, 2)}\n`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const alive = isTeamAlive(state);
|
|
21
|
+
const payload = {
|
|
22
|
+
status: alive ? "active" : "dead",
|
|
23
|
+
alive,
|
|
24
|
+
sessionName: state.sessionName,
|
|
25
|
+
teammateMode: state.teammateMode || "tmux",
|
|
26
|
+
lead: state.lead || "claude",
|
|
27
|
+
agents: state.agents || [],
|
|
28
|
+
startedAt: state.startedAt || null,
|
|
29
|
+
taskCount: (state.tasks || []).length,
|
|
30
|
+
members: (state.members || []).map((member) => ({
|
|
31
|
+
name: member.name,
|
|
32
|
+
cli: member.cli,
|
|
33
|
+
role: member.role,
|
|
34
|
+
pane: member.pane,
|
|
35
|
+
})),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (isNativeMode(state) && alive) {
|
|
39
|
+
payload.nativeMembers = (await nativeGetStatus(state))?.data?.members || [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (alive) {
|
|
43
|
+
payload.hubTasks = await fetchHubTaskList(state);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (json) {
|
|
47
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx multi${RESET} ${alive ? `${GREEN}● active${RESET}` : `${RED}● dead${RESET}`}\n`);
|
|
52
|
+
console.log(` 세션: ${state.sessionName}`);
|
|
53
|
+
console.log(` 모드: ${state.teammateMode || "tmux"}`);
|
|
54
|
+
console.log(` 리드: ${state.lead || "claude"}`);
|
|
55
|
+
console.log(` 워커: ${(state.agents || []).join(", ")}`);
|
|
56
|
+
console.log(` Uptime: ${alive ? `${Math.round((Date.now() - state.startedAt) / 60000)}분` : "-"}`);
|
|
57
|
+
console.log(` 태스크: ${(state.tasks || []).length}`);
|
|
58
|
+
if (isWtMode(state) && !hasWindowsTerminalSession()) {
|
|
59
|
+
console.log(` ${DIM}WT_SESSION 미감지: 생존성은 heuristics로 판정됨${RESET}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const member of state.members || []) {
|
|
63
|
+
console.log(` - ${member.name} (${member.cli}) ${DIM}${member.role}${RESET} ${DIM}${member.pane}${RESET}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isNativeMode(state) && alive) {
|
|
67
|
+
for (const member of payload.nativeMembers) {
|
|
68
|
+
console.log(` • ${member.name}: ${member.status}${formatCompletionSuffix(member)}${member.lastPreview ? ` ${DIM}${member.lastPreview}${RESET}` : ""}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (alive) {
|
|
73
|
+
const hubTasks = payload.hubTasks || await fetchHubTaskList(state);
|
|
74
|
+
if (hubTasks.length) {
|
|
75
|
+
const completed = hubTasks.filter((task) => task.status === "completed").length;
|
|
76
|
+
const failed = hubTasks.filter((task) => task.status === "failed").length;
|
|
77
|
+
console.log(`\n ${BOLD}Hub Tasks${RESET} ${DIM}(${completed}/${hubTasks.length} done)${RESET}`);
|
|
78
|
+
for (const task of hubTasks) {
|
|
79
|
+
const icon = task.status === "completed" ? `${GREEN}✓${RESET}` : task.status === "in_progress" ? `${AMBER}●${RESET}` : task.status === "failed" ? `${RED}✗${RESET}` : `${GRAY}○${RESET}`;
|
|
80
|
+
const owner = task.owner ? ` ${GRAY}[${task.owner}]${RESET}` : "";
|
|
81
|
+
console.log(` ${icon} ${task.subject || task.description?.slice(0, 50) || ""}${owner}`);
|
|
82
|
+
}
|
|
83
|
+
if (failed > 0) console.log(` ${RED}⚠ ${failed}건 실패${RESET}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
console.log("");
|
|
87
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { closeWtSession, killSession, sessionExists } from "../../session.mjs";
|
|
2
|
+
import { DIM, RESET } from "../../shared.mjs";
|
|
3
|
+
import { nativeRequest } from "../services/native-control.mjs";
|
|
4
|
+
import { isNativeMode, 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 teamStop() {
|
|
9
|
+
const state = loadTeamState();
|
|
10
|
+
if (!state) {
|
|
11
|
+
console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (isNativeMode(state)) {
|
|
16
|
+
await nativeRequest(state, "/stop", {});
|
|
17
|
+
try { process.kill(state.native.supervisorPid, "SIGTERM"); } catch {}
|
|
18
|
+
ok(`세션 종료: ${state.sessionName}`);
|
|
19
|
+
} else if (isWtMode(state)) {
|
|
20
|
+
const closed = closeWtSession({ layout: state?.wt?.layout || state?.layout || "1xN", paneCount: state?.wt?.paneCount ?? (state.members || []).length });
|
|
21
|
+
ok(`세션 종료: ${state.sessionName}${closed ? ` (${closed} panes closed)` : ""}`);
|
|
22
|
+
} else if (sessionExists(state.sessionName)) {
|
|
23
|
+
killSession(state.sessionName);
|
|
24
|
+
ok(`세션 종료: ${state.sessionName}`);
|
|
25
|
+
} else {
|
|
26
|
+
console.log(` ${DIM}세션 이미 종료됨${RESET}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
clearTeamState(state.sessionId);
|
|
30
|
+
console.log("");
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { DIM, RESET, WHITE } from "../../shared.mjs";
|
|
2
|
+
import { isTeamAlive } from "../services/runtime-mode.mjs";
|
|
3
|
+
import { loadTeamState, saveTeamState } from "../services/state-store.mjs";
|
|
4
|
+
import { normalizeTaskStatus, updateTaskStatus } from "../services/task-model.mjs";
|
|
5
|
+
import { ok } from "../render.mjs";
|
|
6
|
+
|
|
7
|
+
export function teamTaskUpdate(args = []) {
|
|
8
|
+
const state = loadTeamState();
|
|
9
|
+
if (!state || !isTeamAlive(state)) {
|
|
10
|
+
console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const nextStatus = normalizeTaskStatus(args[0]);
|
|
15
|
+
const taskId = String(args[1] || "").toUpperCase();
|
|
16
|
+
if (!nextStatus || !taskId) {
|
|
17
|
+
console.log(`\n 사용법: ${WHITE}tfx multi task <pending|progress|done> <T1>${RESET}\n`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const updated = updateTaskStatus(state.tasks || [], taskId, nextStatus);
|
|
22
|
+
if (!updated.target) {
|
|
23
|
+
console.log(`\n ${DIM}태스크를 찾을 수 없음: ${taskId}${RESET}\n`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
saveTeamState({ ...state, tasks: updated.tasks }, state.sessionId);
|
|
28
|
+
ok(`${updated.target.id} 상태 갱신: ${nextStatus}`);
|
|
29
|
+
console.log("");
|
|
30
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DIM, RESET } from "../../shared.mjs";
|
|
2
|
+
import { isTeamAlive } from "../services/runtime-mode.mjs";
|
|
3
|
+
import { loadTeamState } from "../services/state-store.mjs";
|
|
4
|
+
import { renderTasks } from "../render.mjs";
|
|
5
|
+
|
|
6
|
+
export function teamTasks() {
|
|
7
|
+
const state = loadTeamState();
|
|
8
|
+
if (!state || !isTeamAlive(state)) {
|
|
9
|
+
console.log(`\n ${DIM}활성 팀 세션 없음${RESET}\n`);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
renderTasks(state.tasks || []);
|
|
13
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { AMBER, BOLD, DIM, GRAY, RESET, WHITE } from "../shared.mjs";
|
|
2
|
+
|
|
3
|
+
export function renderTeamHelp() {
|
|
4
|
+
console.log(`
|
|
5
|
+
${AMBER}${BOLD}⬡ tfx multi${RESET} ${DIM}멀티-CLI 팀 모드 (Lead + Teammates)${RESET}
|
|
6
|
+
|
|
7
|
+
${BOLD}시작${RESET}
|
|
8
|
+
${WHITE}tfx multi "작업 설명"${RESET}
|
|
9
|
+
${WHITE}tfx multi --agents codex,gemini --lead claude "작업"${RESET}
|
|
10
|
+
${WHITE}tfx multi --teammate-mode tmux "작업"${RESET}
|
|
11
|
+
${WHITE}tfx multi --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}
|
|
12
|
+
${WHITE}tfx multi --layout 1xN "작업"${RESET} ${DIM}(세로 분할 컬럼)${RESET}
|
|
13
|
+
${WHITE}tfx multi --layout Nx1 "작업"${RESET} ${DIM}(가로 분할 스택)${RESET}
|
|
14
|
+
${WHITE}tfx multi --dashboard-layout lite "작업"${RESET} ${DIM}(dashboard-lite 기본 뷰)${RESET}
|
|
15
|
+
${WHITE}tfx multi --dashboard-layout auto "작업"${RESET} ${DIM}(dashboard viewer 레이아웃 자동 결정)${RESET}
|
|
16
|
+
${WHITE}tfx multi --dashboard-size 0.4 "작업"${RESET} ${DIM}(대시보드 분할 비율 0.2~0.8, 기본 0.50)${RESET}
|
|
17
|
+
${WHITE}tfx multi --dashboard-anchor window "작업"${RESET} ${DIM}(대시보드 고정 위치: window|tab, 기본 window)${RESET}
|
|
18
|
+
${WHITE}tfx multi --teammate-mode in-process "작업"${RESET} ${DIM}(tmux 불필요)${RESET}
|
|
19
|
+
|
|
20
|
+
${BOLD}제어${RESET}
|
|
21
|
+
${WHITE}tfx multi status${RESET} ${GRAY}현재 팀 상태${RESET}
|
|
22
|
+
${WHITE}tfx multi debug${RESET} ${DIM}[--lines 30]${RESET} ${GRAY}강화 디버그 출력(환경/세션/pane tail)${RESET}
|
|
23
|
+
${WHITE}tfx multi tasks${RESET} ${GRAY}공유 태스크 목록${RESET}
|
|
24
|
+
${WHITE}tfx multi task${RESET} ${DIM}<pending|progress|done> <T1>${RESET} ${GRAY}태스크 상태 갱신${RESET}
|
|
25
|
+
${WHITE}tfx multi attach${RESET} ${DIM}[--wt]${RESET} ${GRAY}세션 재연결 (WT 분할은 opt-in)${RESET}
|
|
26
|
+
${WHITE}tfx multi focus${RESET} ${DIM}<lead|이름|번호> [--wt]${RESET} ${GRAY}특정 팀메이트 포커스${RESET}
|
|
27
|
+
${WHITE}tfx multi send${RESET} ${DIM}<lead|이름|번호> "msg"${RESET} ${GRAY}팀메이트에 메시지 주입${RESET}
|
|
28
|
+
${WHITE}tfx multi interrupt${RESET} ${DIM}<대상>${RESET} ${GRAY}팀메이트 인터럽트(C-c)${RESET}
|
|
29
|
+
${WHITE}tfx multi control${RESET} ${DIM}<대상> <cmd>${RESET} ${GRAY}리드 제어명령(interrupt|stop|pause|resume)${RESET}
|
|
30
|
+
${WHITE}tfx multi stop${RESET} ${GRAY}graceful 종료${RESET}
|
|
31
|
+
${WHITE}tfx multi kill${RESET} ${GRAY}모든 팀 세션 강제 종료${RESET}
|
|
32
|
+
${WHITE}tfx multi list${RESET} ${GRAY}활성 세션 목록${RESET}
|
|
33
|
+
|
|
34
|
+
${BOLD}키 조작(Claude teammate 스타일, tmux 모드)${RESET}
|
|
35
|
+
${WHITE}Shift+Down${RESET} ${GRAY}다음 팀메이트${RESET}
|
|
36
|
+
${WHITE}Shift+Tab${RESET} ${GRAY}이전 팀메이트 (권장)${RESET}
|
|
37
|
+
${WHITE}Shift+Left${RESET} ${GRAY}이전 팀메이트 (대체)${RESET}
|
|
38
|
+
${WHITE}Shift+Up${RESET} ${GRAY}미지원 (Claude Code가 캡처 불가, scroll-up 충돌)${RESET}
|
|
39
|
+
${WHITE}Escape${RESET} ${GRAY}현재 팀메이트 인터럽트${RESET}
|
|
40
|
+
${WHITE}Ctrl+T${RESET} ${GRAY}태스크 목록 토글${RESET}
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { renderTeamHelp } from "./help.mjs";
|
|
2
|
+
import { resolveTeamCommand } from "./manifest.mjs";
|
|
3
|
+
import { teamAttach } from "./commands/attach.mjs";
|
|
4
|
+
import { teamControl } from "./commands/control.mjs";
|
|
5
|
+
import { teamDebug } from "./commands/debug.mjs";
|
|
6
|
+
import { teamFocus } from "./commands/focus.mjs";
|
|
7
|
+
import { teamInterrupt } from "./commands/interrupt.mjs";
|
|
8
|
+
import { teamKill } from "./commands/kill.mjs";
|
|
9
|
+
import { teamList } from "./commands/list.mjs";
|
|
10
|
+
import { teamSend } from "./commands/send.mjs";
|
|
11
|
+
import { teamStart } from "./commands/start/index.mjs";
|
|
12
|
+
import { teamStatus } from "./commands/status.mjs";
|
|
13
|
+
import { teamStop } from "./commands/stop.mjs";
|
|
14
|
+
import { teamTaskUpdate } from "./commands/task.mjs";
|
|
15
|
+
import { teamTasks } from "./commands/tasks.mjs";
|
|
16
|
+
|
|
17
|
+
const handlers = {
|
|
18
|
+
attach: teamAttach,
|
|
19
|
+
control: teamControl,
|
|
20
|
+
debug: teamDebug,
|
|
21
|
+
focus: teamFocus,
|
|
22
|
+
help: renderTeamHelp,
|
|
23
|
+
interrupt: teamInterrupt,
|
|
24
|
+
kill: teamKill,
|
|
25
|
+
list: teamList,
|
|
26
|
+
send: teamSend,
|
|
27
|
+
start: teamStart,
|
|
28
|
+
status: teamStatus,
|
|
29
|
+
stop: teamStop,
|
|
30
|
+
task: teamTaskUpdate,
|
|
31
|
+
tasks: teamTasks,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function cmdTeam() {
|
|
35
|
+
const args = process.argv.slice(3);
|
|
36
|
+
const command = resolveTeamCommand(args[0]);
|
|
37
|
+
if (!args.length) return renderTeamHelp();
|
|
38
|
+
// 미등록 커맨드는 teamStart로 fallthrough (팀 생성 기본값)
|
|
39
|
+
if (!command) return teamStart(args);
|
|
40
|
+
return handlers[command](args.slice(1));
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const TEAM_COMMANDS = [
|
|
2
|
+
{ name: "status", usage: "tfx multi status", desc: "현재 팀 상태" },
|
|
3
|
+
{ name: "debug", usage: "tfx multi debug [--lines 30]", desc: "강화 디버그 출력" },
|
|
4
|
+
{ name: "tasks", usage: "tfx multi tasks", desc: "공유 태스크 목록" },
|
|
5
|
+
{ name: "task", usage: "tfx multi task <pending|progress|done> <T1>", desc: "태스크 상태 갱신" },
|
|
6
|
+
{ name: "attach", usage: "tfx multi attach [--wt]", desc: "세션 재연결" },
|
|
7
|
+
{ name: "focus", usage: "tfx multi focus <lead|이름|번호> [--wt]", desc: "특정 팀메이트 포커스" },
|
|
8
|
+
{ name: "send", usage: "tfx multi send <lead|이름|번호> \"msg\"", desc: "팀메이트에 메시지 주입" },
|
|
9
|
+
{ name: "interrupt", usage: "tfx multi interrupt <대상>", desc: "팀메이트 인터럽트(C-c)" },
|
|
10
|
+
{ name: "control", usage: "tfx multi control <대상> <cmd> [사유]", desc: "리드 제어명령 전송" },
|
|
11
|
+
{ name: "stop", usage: "tfx multi stop", desc: "graceful 종료" },
|
|
12
|
+
{ name: "kill", usage: "tfx multi kill", desc: "모든 팀 세션 강제 종료" },
|
|
13
|
+
{ name: "list", usage: "tfx multi list", desc: "활성 세션 목록" },
|
|
14
|
+
{ name: "help", usage: "tfx multi help", desc: "도움말" },
|
|
15
|
+
{ name: "start", usage: "tfx multi start [options]", desc: "새 팀 세션 시작 (기본 커맨드)" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const TEAM_COMMAND_ALIASES = new Map([
|
|
19
|
+
["-h", "help"],
|
|
20
|
+
["--help", "help"],
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export const TEAM_SUBCOMMANDS = new Set(TEAM_COMMANDS.map(({ name }) => name));
|
|
24
|
+
|
|
25
|
+
export function resolveTeamCommand(raw) {
|
|
26
|
+
if (typeof raw !== "string") return null;
|
|
27
|
+
const command = raw.toLowerCase();
|
|
28
|
+
return TEAM_SUBCOMMANDS.has(command) ? command : (TEAM_COMMAND_ALIASES.get(command) || null);
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AMBER, BOLD, DIM, GRAY, GREEN, RED, RESET, WHITE, YELLOW } from "../shared.mjs";
|
|
2
|
+
|
|
3
|
+
export function ok(msg) { console.log(` ${GREEN}✓${RESET} ${msg}`); }
|
|
4
|
+
export function warn(msg) { console.log(` ${YELLOW}⚠${RESET} ${msg}`); }
|
|
5
|
+
export function fail(msg) { console.log(` ${RED}✗${RESET} ${msg}`); }
|
|
6
|
+
|
|
7
|
+
export function renderTasks(tasks = []) {
|
|
8
|
+
if (!tasks.length) {
|
|
9
|
+
console.log(`\n ${DIM}태스크 없음${RESET}\n`);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
console.log(`\n ${AMBER}${BOLD}⬡ Team Tasks${RESET}\n`);
|
|
14
|
+
for (const task of tasks) {
|
|
15
|
+
const dep = task.depends_on?.length ? ` ${DIM}(deps: ${task.depends_on.join(",")})${RESET}` : "";
|
|
16
|
+
const owner = task.owner ? ` ${GRAY}[${task.owner}]${RESET}` : "";
|
|
17
|
+
console.log(` ${WHITE}${task.id}${RESET} ${String(task.status || "").padEnd(11)} ${task.title}${owner}${dep}`);
|
|
18
|
+
}
|
|
19
|
+
console.log("");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatCompletionSuffix(member) {
|
|
23
|
+
if (!member?.completionStatus) return "";
|
|
24
|
+
if (member.completionStatus === "abnormal") {
|
|
25
|
+
return ` ${RED}[abnormal:${member.completionReason || "unknown"}]${RESET}`;
|
|
26
|
+
}
|
|
27
|
+
if (member.completionStatus === "normal") return ` ${GREEN}[route-ok]${RESET}`;
|
|
28
|
+
if (member.completionStatus === "unchecked") return ` ${GRAY}[route-unchecked]${RESET}`;
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getSessionAttachedCount,
|
|
5
|
+
hasWindowsTerminal,
|
|
6
|
+
resolveAttachCommand,
|
|
7
|
+
} from "../../session.mjs";
|
|
8
|
+
import { PKG_ROOT } from "./state-store.mjs";
|
|
9
|
+
|
|
10
|
+
export async function launchAttachInWindowsTerminal(sessionName) {
|
|
11
|
+
if (!hasWindowsTerminal()) return false;
|
|
12
|
+
|
|
13
|
+
let attachSpec;
|
|
14
|
+
try {
|
|
15
|
+
attachSpec = resolveAttachCommand(sessionName);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const beforeAttached = getSessionAttachedCount(sessionName);
|
|
21
|
+
try {
|
|
22
|
+
const child = spawn("wt", ["-w", "0", "split-pane", "-V", "-d", PKG_ROOT, attachSpec.command, ...attachSpec.args], {
|
|
23
|
+
detached: true,
|
|
24
|
+
stdio: "ignore",
|
|
25
|
+
windowsHide: false,
|
|
26
|
+
});
|
|
27
|
+
child.unref();
|
|
28
|
+
|
|
29
|
+
if (beforeAttached == null) return true;
|
|
30
|
+
const deadline = Date.now() + 3500;
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
33
|
+
const nowAttached = getSessionAttachedCount(sessionName);
|
|
34
|
+
if (typeof nowAttached === "number" && nowAttached > beforeAttached) return true;
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildManualAttachCommand(sessionName) {
|
|
41
|
+
try {
|
|
42
|
+
const spec = resolveAttachCommand(sessionName);
|
|
43
|
+
return [spec.command, ...spec.args].map((value) => {
|
|
44
|
+
const text = String(value);
|
|
45
|
+
return /\s/.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
|
|
46
|
+
}).join(" ");
|
|
47
|
+
} catch {
|
|
48
|
+
return `tmux attach-session -t ${sessionName}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function wantsWtAttachFallback(args = [], env = process.env) {
|
|
53
|
+
return args.includes("--wt") || args.includes("--spawn-wt") || env.TFX_ATTACH_WT_AUTO === "1";
|
|
54
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
import { HUB_PID_DIR, PKG_ROOT } from "./state-store.mjs";
|
|
6
|
+
export { nativeGetStatus } from "./native-control.mjs";
|
|
7
|
+
|
|
8
|
+
const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
|
|
9
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
10
|
+
|
|
11
|
+
export function formatHostForUrl(host) {
|
|
12
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildHubBaseUrl(host, port) {
|
|
16
|
+
return `http://${formatHostForUrl(host)}:${port}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getDefaultHubPort() {
|
|
20
|
+
const envPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
|
|
21
|
+
return Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : 27888;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getDefaultHubUrl() {
|
|
25
|
+
return `${buildHubBaseUrl("127.0.0.1", getDefaultHubPort())}/mcp`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeLoopbackHost(host) {
|
|
29
|
+
if (typeof host !== "string") return "127.0.0.1";
|
|
30
|
+
const candidate = host.trim();
|
|
31
|
+
return LOOPBACK_HOSTS.has(candidate) ? candidate : "127.0.0.1";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function probeHubStatus(host, port, timeoutMs = 1500) {
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(`${buildHubBaseUrl(host, port)}/status`, {
|
|
37
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) return null;
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
return data?.hub ? data : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function getHubInfo() {
|
|
48
|
+
const probePort = getDefaultHubPort();
|
|
49
|
+
|
|
50
|
+
if (existsSync(HUB_PID_FILE)) {
|
|
51
|
+
try {
|
|
52
|
+
const raw = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
53
|
+
const pid = Number(raw?.pid);
|
|
54
|
+
if (!Number.isFinite(pid) || pid <= 0) throw new Error("invalid pid");
|
|
55
|
+
process.kill(pid, 0);
|
|
56
|
+
const host = normalizeLoopbackHost(raw?.host);
|
|
57
|
+
const port = Number(raw?.port) || 27888;
|
|
58
|
+
const status = await probeHubStatus(host, port, 1200);
|
|
59
|
+
return {
|
|
60
|
+
...raw,
|
|
61
|
+
pid,
|
|
62
|
+
host,
|
|
63
|
+
port,
|
|
64
|
+
url: `${buildHubBaseUrl(host, port)}/mcp`,
|
|
65
|
+
...(status ? {} : { degraded: true }),
|
|
66
|
+
};
|
|
67
|
+
} catch {
|
|
68
|
+
try { unlinkSync(HUB_PID_FILE); } catch {}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const portCandidate of Array.from(new Set([probePort, 27888]))) {
|
|
73
|
+
const data = await probeHubStatus("127.0.0.1", portCandidate, 1200);
|
|
74
|
+
if (!data) continue;
|
|
75
|
+
const port = Number(data.port) || portCandidate;
|
|
76
|
+
const pid = Number(data.pid);
|
|
77
|
+
const recovered = {
|
|
78
|
+
pid: Number.isFinite(pid) ? pid : null,
|
|
79
|
+
host: "127.0.0.1",
|
|
80
|
+
port,
|
|
81
|
+
url: `${buildHubBaseUrl("127.0.0.1", port)}/mcp`,
|
|
82
|
+
discovered: true,
|
|
83
|
+
};
|
|
84
|
+
if (Number.isFinite(recovered.pid) && recovered.pid > 0) {
|
|
85
|
+
try {
|
|
86
|
+
mkdirSync(HUB_PID_DIR, { recursive: true });
|
|
87
|
+
writeFileSync(HUB_PID_FILE, JSON.stringify({ ...recovered, started: Date.now() }));
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
return recovered;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function startHubDaemon() {
|
|
96
|
+
const serverPath = join(PKG_ROOT, "hub", "server.mjs");
|
|
97
|
+
if (!existsSync(serverPath)) {
|
|
98
|
+
const error = new Error("hub/server.mjs 없음");
|
|
99
|
+
error.code = "HUB_SERVER_MISSING";
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
104
|
+
env: { ...process.env },
|
|
105
|
+
stdio: "ignore",
|
|
106
|
+
detached: true,
|
|
107
|
+
windowsHide: true,
|
|
108
|
+
});
|
|
109
|
+
child.unref();
|
|
110
|
+
|
|
111
|
+
const expectedPort = getDefaultHubPort();
|
|
112
|
+
const deadline = Date.now() + 3000;
|
|
113
|
+
while (Date.now() < deadline) {
|
|
114
|
+
const status = await probeHubStatus("127.0.0.1", expectedPort, 500);
|
|
115
|
+
if (status?.hub) {
|
|
116
|
+
return {
|
|
117
|
+
pid: Number(status.pid) || child.pid,
|
|
118
|
+
host: "127.0.0.1",
|
|
119
|
+
port: expectedPort,
|
|
120
|
+
url: `${buildHubBaseUrl("127.0.0.1", expectedPort)}/mcp`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Hub가 살아있는지 확인하고, 죽어있으면 재시작을 시도한다.
|
|
131
|
+
* exponential backoff: 1초, 2초, 4초
|
|
132
|
+
* 모든 재시작 실패 시 에러를 throw한다 (silent fail 아님).
|
|
133
|
+
* @param {number} [maxRetries=3]
|
|
134
|
+
* @returns {Promise<object>} Hub 정보
|
|
135
|
+
* @throws {Error} 모든 재시작 시도 실패 시
|
|
136
|
+
*/
|
|
137
|
+
export async function ensureHubAlive(maxRetries = 3) {
|
|
138
|
+
const hub = await getHubInfo();
|
|
139
|
+
if (hub && !hub.degraded) return hub;
|
|
140
|
+
|
|
141
|
+
let lastError = null;
|
|
142
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
143
|
+
try {
|
|
144
|
+
const restarted = await startHubDaemon();
|
|
145
|
+
if (restarted) {
|
|
146
|
+
// 재시작 후 연결 복구 확인
|
|
147
|
+
const recovered = await getHubInfo();
|
|
148
|
+
if (recovered) return recovered;
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
lastError = err;
|
|
152
|
+
}
|
|
153
|
+
// 다음 재시도 전 대기: 1초, 2초, 4초 (마지막 시도 후에는 대기 없음)
|
|
154
|
+
if (i < maxRetries - 1) {
|
|
155
|
+
const backoffMs = Math.pow(2, i) * 1000; // i=0: 1초, i=1: 2초, i=2: 4초
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const error = new Error(`Hub 재시작 ${maxRetries}회 모두 실패${lastError ? `: ${lastError.message}` : ""}`);
|
|
161
|
+
error.code = "HUB_RESTART_FAILED";
|
|
162
|
+
error.cause = lastError;
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function fetchHubTaskList(state) {
|
|
167
|
+
const hubBase = (state?.hubUrl || getDefaultHubUrl()).replace(/\/mcp$/, "");
|
|
168
|
+
const teamName = state?.native?.teamName || state?.sessionName || null;
|
|
169
|
+
if (!teamName) return [];
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const res = await fetch(`${hubBase}/bridge/team/task-list`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "Content-Type": "application/json" },
|
|
175
|
+
body: JSON.stringify({ team_name: teamName }),
|
|
176
|
+
signal: AbortSignal.timeout(2000),
|
|
177
|
+
});
|
|
178
|
+
const data = await res.json();
|
|
179
|
+
return data?.ok ? (data.data?.tasks || []) : [];
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function publishLeadControl(state, targetMember, command, reason = "") {
|
|
186
|
+
const hubBase = (state?.hubUrl || getDefaultHubUrl()).replace(/\/mcp$/, "");
|
|
187
|
+
const leadAgent = (state?.members || []).find((member) => member.role === "lead")?.agentId || "lead";
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const res = await fetch(`${hubBase}/bridge/control`, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: { "Content-Type": "application/json" },
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
from_agent: leadAgent,
|
|
195
|
+
to_agent: targetMember.agentId,
|
|
196
|
+
command,
|
|
197
|
+
reason,
|
|
198
|
+
payload: {
|
|
199
|
+
issued_by: leadAgent,
|
|
200
|
+
issued_at: Date.now(),
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
204
|
+
return !!res.ok;
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function resolveMember(state, selector) {
|
|
2
|
+
const members = state?.members || [];
|
|
3
|
+
if (!selector) return null;
|
|
4
|
+
|
|
5
|
+
const direct = members.find((member) => (
|
|
6
|
+
member.name === selector || member.role === selector || member.agentId === selector
|
|
7
|
+
));
|
|
8
|
+
if (direct) return direct;
|
|
9
|
+
|
|
10
|
+
const workerAlias = /^worker-(\d+)$/i.exec(selector);
|
|
11
|
+
if (workerAlias) {
|
|
12
|
+
const index = parseInt(workerAlias[1], 10) - 1;
|
|
13
|
+
const workers = members.filter((member) => member.role === "worker");
|
|
14
|
+
if (index >= 0 && index < workers.length) return workers[index];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const numeric = parseInt(selector, 10);
|
|
18
|
+
if (!Number.isNaN(numeric)) {
|
|
19
|
+
const byPane = members.find((member) => member.pane?.endsWith(`.${numeric}`) || member.pane?.endsWith(`:${numeric}`));
|
|
20
|
+
if (byPane) return byPane;
|
|
21
|
+
if (numeric >= 1 && numeric <= members.length) return members[numeric - 1];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toAgentId(cli, target) {
|
|
28
|
+
const suffix = String(target).split(/[:.]/).pop();
|
|
29
|
+
return `${cli}-${suffix}`;
|
|
30
|
+
}
|