triflux 3.2.0-dev.5 → 3.2.0-dev.7
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/README.ko.md +7 -5
- package/README.md +7 -5
- package/bin/triflux.mjs +156 -75
- package/hub/server.mjs +19 -10
- package/hub/team/cli.mjs +219 -102
- package/hub/team/session.mjs +39 -23
- package/package.json +1 -1
- package/scripts/hub-ensure.mjs +83 -0
- package/scripts/setup.mjs +65 -37
- package/skills/tfx-team/SKILL.md +19 -1
package/README.ko.md
CHANGED
|
@@ -212,11 +212,13 @@ tfx doctor
|
|
|
212
212
|
|--------|------|
|
|
213
213
|
| `tfx setup` | 스크립트 + HUD + 스킬 동기화 |
|
|
214
214
|
| `tfx doctor` | CLI 진단 + 이슈 추적 |
|
|
215
|
-
| `tfx update` | 최신 버전으로 업데이트 |
|
|
216
|
-
| `tfx list` | 설치된 스킬 목록 |
|
|
217
|
-
| `tfx version` | 버전 표시 |
|
|
218
|
-
|
|
219
|
-
축약: `tfx` = `triflux`, `tfl` = `triflux`
|
|
215
|
+
| `tfx update` | 최신 안정 버전으로 업데이트 |
|
|
216
|
+
| `tfx list` | 설치된 스킬 목록 |
|
|
217
|
+
| `tfx version` | 버전 표시 |
|
|
218
|
+
|
|
219
|
+
축약: `tfx` = `triflux`, `tfl` = `triflux`
|
|
220
|
+
|
|
221
|
+
dev 채널 업데이트: `tfx update --dev` (`dev` 별칭 지원)
|
|
220
222
|
|
|
221
223
|
### HUD 상태 표시줄
|
|
222
224
|
|
package/README.md
CHANGED
|
@@ -212,11 +212,13 @@ tfx doctor
|
|
|
212
212
|
|---------|-------------|
|
|
213
213
|
| `tfx setup` | Sync scripts + HUD + skills |
|
|
214
214
|
| `tfx doctor` | CLI diagnostics + issue tracker |
|
|
215
|
-
| `tfx update` | Update to latest version |
|
|
216
|
-
| `tfx list` | List installed skills |
|
|
217
|
-
| `tfx version` | Show version info |
|
|
218
|
-
|
|
219
|
-
Shortcuts: `tfx` = `triflux`, `tfl` = `triflux`
|
|
215
|
+
| `tfx update` | Update to latest stable version |
|
|
216
|
+
| `tfx list` | List installed skills |
|
|
217
|
+
| `tfx version` | Show version info |
|
|
218
|
+
|
|
219
|
+
Shortcuts: `tfx` = `triflux`, `tfl` = `triflux`
|
|
220
|
+
|
|
221
|
+
Dev channel update: `tfx update --dev` (`dev` alias supported)
|
|
220
222
|
|
|
221
223
|
### HUD Status Bar
|
|
222
224
|
|
package/bin/triflux.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync } from "fs";
|
|
4
4
|
import { join, dirname } from "path";
|
|
5
5
|
import { homedir } from "os";
|
|
6
|
-
import { execSync, spawn } from "child_process";
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { execSync, spawn } from "child_process";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
|
|
9
|
+
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
9
10
|
const CLAUDE_DIR = join(homedir(), ".claude");
|
|
10
11
|
const CODEX_DIR = join(homedir(), ".codex");
|
|
11
12
|
const CODEX_CONFIG_PATH = join(CODEX_DIR, "config.toml");
|
|
@@ -67,7 +68,7 @@ function which(cmd) {
|
|
|
67
68
|
} catch { return null; }
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
function whichInShell(cmd, shell) {
|
|
71
|
+
function whichInShell(cmd, shell) {
|
|
71
72
|
const cmds = {
|
|
72
73
|
bash: `bash -c "source ~/.bashrc 2>/dev/null && command -v ${cmd} 2>/dev/null"`,
|
|
73
74
|
cmd: `cmd /c where ${cmd} 2>nul`,
|
|
@@ -82,8 +83,12 @@ function whichInShell(cmd, shell) {
|
|
|
82
83
|
stdio: ["pipe", "pipe", "ignore"],
|
|
83
84
|
}).trim();
|
|
84
85
|
return result.split(/\r?\n/)[0] || null;
|
|
85
|
-
} catch { return null; }
|
|
86
|
-
}
|
|
86
|
+
} catch { return null; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isDevUpdateRequested(argv = process.argv) {
|
|
90
|
+
return argv.includes("--dev") || argv.includes("@dev") || argv.includes("dev");
|
|
91
|
+
}
|
|
87
92
|
|
|
88
93
|
function checkShellAvailable(shell) {
|
|
89
94
|
const cmds = { bash: "bash --version", cmd: "cmd /c echo ok", pwsh: "pwsh -NoProfile -c echo ok" };
|
|
@@ -676,9 +681,9 @@ function cmdDoctor(options = {}) {
|
|
|
676
681
|
}
|
|
677
682
|
}
|
|
678
683
|
|
|
679
|
-
function cmdUpdate() {
|
|
680
|
-
const isDev = process.argv
|
|
681
|
-
const tagLabel = isDev ? ` ${YELLOW}
|
|
684
|
+
function cmdUpdate() {
|
|
685
|
+
const isDev = isDevUpdateRequested(process.argv);
|
|
686
|
+
const tagLabel = isDev ? ` ${YELLOW}--dev${RESET}` : "";
|
|
682
687
|
console.log(`\n${BOLD}triflux update${RESET}${tagLabel}\n`);
|
|
683
688
|
|
|
684
689
|
// 1. 설치 방식 감지
|
|
@@ -755,7 +760,7 @@ function cmdUpdate() {
|
|
|
755
760
|
timeout: 60000,
|
|
756
761
|
stdio: ["pipe", "pipe", "ignore"],
|
|
757
762
|
}).trim().split(/\r?\n/)[0];
|
|
758
|
-
ok(`${isDev ? "npm install -g @dev" : "npm update -g"} — ${result || "완료"}`);
|
|
763
|
+
ok(`${isDev ? "npm install -g triflux@dev" : "npm update -g triflux"} — ${result || "완료"}`);
|
|
759
764
|
updated = true;
|
|
760
765
|
break;
|
|
761
766
|
}
|
|
@@ -767,7 +772,7 @@ function cmdUpdate() {
|
|
|
767
772
|
cwd: process.cwd(),
|
|
768
773
|
stdio: ["pipe", "pipe", "ignore"],
|
|
769
774
|
}).trim().split(/\r?\n/)[0];
|
|
770
|
-
ok(
|
|
775
|
+
ok(`${isDev ? "npm install triflux@dev" : "npm update triflux"} — ${result || "완료"}`);
|
|
771
776
|
updated = true;
|
|
772
777
|
break;
|
|
773
778
|
}
|
|
@@ -914,8 +919,8 @@ ${updateNotice}
|
|
|
914
919
|
${WHITE_BRIGHT}tfx doctor${RESET} ${GRAY}CLI 진단 + 이슈 확인${RESET}
|
|
915
920
|
${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
|
|
916
921
|
${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
|
|
917
|
-
${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 버전으로 업데이트${RESET}
|
|
918
|
-
${DIM} --dev${RESET}
|
|
922
|
+
${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
|
|
923
|
+
${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
|
|
919
924
|
${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
|
|
920
925
|
${WHITE_BRIGHT}tfx hub${RESET} ${GRAY}MCP 메시지 버스 관리 (start/stop/status)${RESET}
|
|
921
926
|
${WHITE_BRIGHT}tfx team${RESET} ${GRAY}멀티-CLI 팀 모드 (tmux + Hub)${RESET}
|
|
@@ -961,27 +966,31 @@ async function cmdCodexTeam() {
|
|
|
961
966
|
return;
|
|
962
967
|
}
|
|
963
968
|
|
|
964
|
-
const hasAgents = args.includes("--agents");
|
|
965
|
-
const hasLead = args.includes("--lead");
|
|
966
|
-
const hasLayout = args.includes("--layout");
|
|
967
|
-
const isControl = passthrough.has(sub);
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
if (!isControl && !
|
|
971
|
-
if (!isControl && !
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
}
|
|
984
|
-
|
|
969
|
+
const hasAgents = args.includes("--agents");
|
|
970
|
+
const hasLead = args.includes("--lead");
|
|
971
|
+
const hasLayout = args.includes("--layout");
|
|
972
|
+
const isControl = passthrough.has(sub);
|
|
973
|
+
const normalizedArgs = isControl && args.length ? [sub, ...args.slice(1)] : args;
|
|
974
|
+
const inject = [];
|
|
975
|
+
if (!isControl && !hasLead) inject.push("--lead", "codex");
|
|
976
|
+
if (!isControl && !hasAgents) inject.push("--agents", "codex,codex");
|
|
977
|
+
if (!isControl && !hasLayout) inject.push("--layout", "1xN");
|
|
978
|
+
const forwarded = isControl ? normalizedArgs : [...inject, ...args];
|
|
979
|
+
|
|
980
|
+
const prevArgv = process.argv;
|
|
981
|
+
const prevProfile = process.env.TFX_TEAM_PROFILE;
|
|
982
|
+
process.env.TFX_TEAM_PROFILE = "codex-team";
|
|
983
|
+
const { pathToFileURL } = await import("node:url");
|
|
984
|
+
const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
|
|
985
|
+
process.argv = [prevArgv[0], prevArgv[1], "team", ...forwarded];
|
|
986
|
+
try {
|
|
987
|
+
await cmdTeam();
|
|
988
|
+
} finally {
|
|
989
|
+
process.argv = prevArgv;
|
|
990
|
+
if (typeof prevProfile === "string") process.env.TFX_TEAM_PROFILE = prevProfile;
|
|
991
|
+
else delete process.env.TFX_TEAM_PROFILE;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
985
994
|
|
|
986
995
|
// ── hub 서브커맨드 ──
|
|
987
996
|
|
|
@@ -1062,10 +1071,40 @@ function autoRegisterMcp(mcpUrl) {
|
|
|
1062
1071
|
} catch (e) { warn(`Claude 등록 실패: ${e.message}`); }
|
|
1063
1072
|
}
|
|
1064
1073
|
|
|
1065
|
-
function cmdHub() {
|
|
1066
|
-
const sub = process.argv[3] || "status";
|
|
1067
|
-
|
|
1068
|
-
|
|
1074
|
+
async function cmdHub() {
|
|
1075
|
+
const sub = process.argv[3] || "status";
|
|
1076
|
+
const defaultPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
|
|
1077
|
+
const probePort = Number.isFinite(defaultPortRaw) && defaultPortRaw > 0 ? defaultPortRaw : 27888;
|
|
1078
|
+
const formatHostForUrl = (host) => host.includes(":") ? `[${host}]` : host;
|
|
1079
|
+
const probeHubStatus = async (host = "127.0.0.1", port = probePort, timeoutMs = 3000) => {
|
|
1080
|
+
try {
|
|
1081
|
+
const res = await fetch(`http://${formatHostForUrl(host)}:${port}/status`, {
|
|
1082
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1083
|
+
});
|
|
1084
|
+
if (!res.ok) return null;
|
|
1085
|
+
const data = await res.json();
|
|
1086
|
+
return data?.hub ? data : null;
|
|
1087
|
+
} catch {
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
const recoverPidFile = (statusData, defaultHost = "127.0.0.1") => {
|
|
1092
|
+
const pid = Number(statusData?.pid);
|
|
1093
|
+
const port = Number(statusData?.port) || probePort;
|
|
1094
|
+
if (!Number.isFinite(pid) || pid <= 0) return;
|
|
1095
|
+
try {
|
|
1096
|
+
mkdirSync(HUB_PID_DIR, { recursive: true });
|
|
1097
|
+
writeFileSync(HUB_PID_FILE, JSON.stringify({
|
|
1098
|
+
pid,
|
|
1099
|
+
port,
|
|
1100
|
+
host: defaultHost,
|
|
1101
|
+
url: `http://${formatHostForUrl(defaultHost)}:${port}/mcp`,
|
|
1102
|
+
started: Date.now(),
|
|
1103
|
+
}));
|
|
1104
|
+
} catch {}
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
switch (sub) {
|
|
1069
1108
|
case "start": {
|
|
1070
1109
|
// 이미 실행 중인지 확인
|
|
1071
1110
|
if (existsSync(HUB_PID_FILE)) {
|
|
@@ -1096,13 +1135,13 @@ function cmdHub() {
|
|
|
1096
1135
|
});
|
|
1097
1136
|
child.unref();
|
|
1098
1137
|
|
|
1099
|
-
// PID 파일 확인 (최대 3초 대기, 100ms 폴링)
|
|
1100
|
-
let started = false;
|
|
1101
|
-
const deadline = Date.now() + 3000;
|
|
1102
|
-
while (Date.now() < deadline) {
|
|
1103
|
-
if (existsSync(HUB_PID_FILE)) { started = true; break; }
|
|
1104
|
-
|
|
1105
|
-
}
|
|
1138
|
+
// PID 파일 확인 (최대 3초 대기, 100ms 폴링)
|
|
1139
|
+
let started = false;
|
|
1140
|
+
const deadline = Date.now() + 3000;
|
|
1141
|
+
while (Date.now() < deadline) {
|
|
1142
|
+
if (existsSync(HUB_PID_FILE)) { started = true; break; }
|
|
1143
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1144
|
+
}
|
|
1106
1145
|
|
|
1107
1146
|
if (started) {
|
|
1108
1147
|
const hubInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
@@ -1121,11 +1160,20 @@ function cmdHub() {
|
|
|
1121
1160
|
break;
|
|
1122
1161
|
}
|
|
1123
1162
|
|
|
1124
|
-
case "stop": {
|
|
1125
|
-
if (!existsSync(HUB_PID_FILE)) {
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1163
|
+
case "stop": {
|
|
1164
|
+
if (!existsSync(HUB_PID_FILE)) {
|
|
1165
|
+
const probed = await probeHubStatus("127.0.0.1", probePort, 1500)
|
|
1166
|
+
|| (probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500));
|
|
1167
|
+
if (probed && Number.isFinite(Number(probed.pid))) {
|
|
1168
|
+
try {
|
|
1169
|
+
process.kill(Number(probed.pid), "SIGTERM");
|
|
1170
|
+
console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${probed.pid})${DIM} (probe)${RESET}\n`);
|
|
1171
|
+
return;
|
|
1172
|
+
} catch {}
|
|
1173
|
+
}
|
|
1174
|
+
console.log(`\n ${DIM}hub 미실행${RESET}\n`);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1129
1177
|
try {
|
|
1130
1178
|
const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
1131
1179
|
process.kill(info.pid, "SIGTERM");
|
|
@@ -1137,14 +1185,36 @@ function cmdHub() {
|
|
|
1137
1185
|
}
|
|
1138
1186
|
break;
|
|
1139
1187
|
}
|
|
1140
|
-
|
|
1141
|
-
case "status": {
|
|
1142
|
-
if (!existsSync(HUB_PID_FILE)) {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1188
|
+
|
|
1189
|
+
case "status": {
|
|
1190
|
+
if (!existsSync(HUB_PID_FILE)) {
|
|
1191
|
+
const probed = await probeHubStatus();
|
|
1192
|
+
if (!probed) {
|
|
1193
|
+
const fallback = probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500);
|
|
1194
|
+
if (fallback) {
|
|
1195
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(default port probe 성공)${RESET}`);
|
|
1196
|
+
console.log(` URL: http://127.0.0.1:${fallback.port || 27888}/mcp`);
|
|
1197
|
+
if (fallback.pid !== undefined) console.log(` PID: ${fallback.pid}`);
|
|
1198
|
+
if (fallback.hub?.state) console.log(` State: ${fallback.hub.state}`);
|
|
1199
|
+
if (fallback.sessions !== undefined) console.log(` Sessions: ${fallback.sessions}`);
|
|
1200
|
+
recoverPidFile(fallback, "127.0.0.1");
|
|
1201
|
+
console.log("");
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(pid file 없음 / probe 성공)${RESET}`);
|
|
1208
|
+
console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
|
|
1209
|
+
if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
|
|
1210
|
+
if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
|
|
1211
|
+
if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
|
|
1212
|
+
recoverPidFile(probed, "127.0.0.1");
|
|
1213
|
+
console.log("");
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
try {
|
|
1217
|
+
const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
1148
1218
|
process.kill(info.pid, 0); // 생존 확인
|
|
1149
1219
|
const uptime = Date.now() - info.started;
|
|
1150
1220
|
const uptimeStr = uptime < 60000 ? `${Math.round(uptime / 1000)}초`
|
|
@@ -1154,28 +1224,39 @@ function cmdHub() {
|
|
|
1154
1224
|
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`);
|
|
1155
1225
|
console.log(` URL: ${info.url}`);
|
|
1156
1226
|
console.log(` PID: ${info.pid}`);
|
|
1157
|
-
console.log(` Uptime: ${uptimeStr}`);
|
|
1158
|
-
|
|
1159
|
-
// HTTP 상태 조회 시도
|
|
1160
|
-
try {
|
|
1161
|
-
const
|
|
1162
|
-
const
|
|
1163
|
-
const data =
|
|
1164
|
-
if (data.hub) {
|
|
1165
|
-
console.log(` State: ${data.hub.state}`);
|
|
1166
|
-
}
|
|
1227
|
+
console.log(` Uptime: ${uptimeStr}`);
|
|
1228
|
+
|
|
1229
|
+
// HTTP 상태 조회 시도
|
|
1230
|
+
try {
|
|
1231
|
+
const host = typeof info.host === "string" ? info.host : "127.0.0.1";
|
|
1232
|
+
const port = Number(info.port) || probePort;
|
|
1233
|
+
const data = await probeHubStatus(host, port, 3000);
|
|
1234
|
+
if (data.hub) {
|
|
1235
|
+
console.log(` State: ${data.hub.state}`);
|
|
1236
|
+
}
|
|
1167
1237
|
if (data.sessions !== undefined) {
|
|
1168
1238
|
console.log(` Sessions: ${data.sessions}`);
|
|
1169
1239
|
}
|
|
1170
1240
|
} catch {}
|
|
1171
1241
|
|
|
1172
1242
|
console.log("");
|
|
1173
|
-
} catch {
|
|
1174
|
-
try { unlinkSync(HUB_PID_FILE); } catch {}
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1243
|
+
} catch {
|
|
1244
|
+
try { unlinkSync(HUB_PID_FILE); } catch {}
|
|
1245
|
+
const probed = await probeHubStatus();
|
|
1246
|
+
if (!probed) {
|
|
1247
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(stale PID 정리 후 probe 성공)${RESET}`);
|
|
1251
|
+
console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
|
|
1252
|
+
if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
|
|
1253
|
+
if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
|
|
1254
|
+
if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
|
|
1255
|
+
recoverPidFile(probed, "127.0.0.1");
|
|
1256
|
+
console.log("");
|
|
1257
|
+
}
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1179
1260
|
|
|
1180
1261
|
default:
|
|
1181
1262
|
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
|
|
@@ -1200,7 +1281,7 @@ switch (cmd) {
|
|
|
1200
1281
|
}
|
|
1201
1282
|
case "update": cmdUpdate(); break;
|
|
1202
1283
|
case "list": case "ls": cmdList(); break;
|
|
1203
|
-
case "hub": cmdHub(); break;
|
|
1284
|
+
case "hub": await cmdHub(); break;
|
|
1204
1285
|
case "team": {
|
|
1205
1286
|
const { pathToFileURL } = await import("node:url");
|
|
1206
1287
|
const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
|
package/hub/server.mjs
CHANGED
|
@@ -116,16 +116,25 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1' } = {}
|
|
|
116
116
|
return res.end();
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
// /status —
|
|
120
|
-
if (req.url === '/' || req.url === '/status') {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
119
|
+
// /status — 상세 상태 (브라우저/curl 용)
|
|
120
|
+
if (req.url === '/' || req.url === '/status') {
|
|
121
|
+
const status = router.getStatus('hub').data;
|
|
122
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
123
|
+
return res.end(JSON.stringify({
|
|
124
|
+
...status,
|
|
125
|
+
sessions: transports.size,
|
|
126
|
+
pid: process.pid,
|
|
127
|
+
port,
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// /health, /healthz — 최소 헬스 응답 (레거시 호환)
|
|
132
|
+
if (req.url === '/health' || req.url === '/healthz') {
|
|
133
|
+
const status = router.getStatus('hub').data;
|
|
134
|
+
const healthy = status?.hub?.state === 'healthy';
|
|
135
|
+
res.writeHead(healthy ? 200 : 503, { 'Content-Type': 'application/json' });
|
|
136
|
+
return res.end(JSON.stringify({ ok: healthy }));
|
|
137
|
+
}
|
|
129
138
|
|
|
130
139
|
// /bridge/* — 경량 REST 엔드포인트 (tfx-route.sh 브릿지용)
|
|
131
140
|
if (req.url.startsWith('/bridge')) {
|
package/hub/team/cli.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// hub/team/cli.mjs — tfx team CLI 진입점
|
|
2
2
|
// bin/triflux.mjs에서 import하여 사용
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
4
|
-
import { join, dirname } from "node:path";
|
|
5
|
-
import { homedir } from "node:os";
|
|
6
|
-
import {
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
createSession,
|
|
@@ -27,10 +27,18 @@ import { buildCliCommand, startCliInPane, injectPrompt, sendKeys } from "./pane.
|
|
|
27
27
|
import { orchestrate, decomposeTask, buildLeadPrompt, buildPrompt } from "./orchestrator.mjs";
|
|
28
28
|
|
|
29
29
|
// ── 상수 ──
|
|
30
|
-
const PKG_ROOT = dirname(dirname(dirname(new URL(import.meta.url).pathname))).replace(/^\/([A-Z]:)/, "$1");
|
|
31
|
-
const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
32
|
-
const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
|
|
33
|
-
const
|
|
30
|
+
const PKG_ROOT = dirname(dirname(dirname(new URL(import.meta.url).pathname))).replace(/^\/([A-Z]:)/, "$1");
|
|
31
|
+
const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
32
|
+
const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
|
|
33
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
34
|
+
const TEAM_PROFILE = (() => {
|
|
35
|
+
const raw = String(process.env.TFX_TEAM_PROFILE || "team").trim().toLowerCase();
|
|
36
|
+
return raw === "codex-team" ? "codex-team" : "team";
|
|
37
|
+
})();
|
|
38
|
+
const TEAM_STATE_FILE = join(
|
|
39
|
+
HUB_PID_DIR,
|
|
40
|
+
TEAM_PROFILE === "codex-team" ? "team-state-codex-team.json" : "team-state.json",
|
|
41
|
+
);
|
|
34
42
|
|
|
35
43
|
const TEAM_SUBCOMMANDS = new Set([
|
|
36
44
|
"status", "attach", "stop", "kill", "send", "list", "help", "tasks", "task", "focus", "interrupt", "control", "debug",
|
|
@@ -61,52 +69,149 @@ function loadTeamState() {
|
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
function saveTeamState(state) {
|
|
65
|
-
mkdirSync(HUB_PID_DIR, { recursive: true });
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
72
|
+
function saveTeamState(state) {
|
|
73
|
+
mkdirSync(HUB_PID_DIR, { recursive: true });
|
|
74
|
+
const nextState = { ...state, profile: TEAM_PROFILE };
|
|
75
|
+
writeFileSync(TEAM_STATE_FILE, JSON.stringify(nextState, null, 2) + "\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clearTeamState() {
|
|
79
|
+
try { unlinkSync(TEAM_STATE_FILE); } catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Hub 유틸 ──
|
|
83
|
+
|
|
84
|
+
function formatHostForUrl(host) {
|
|
85
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildHubBaseUrl(host, port) {
|
|
89
|
+
return `http://${formatHostForUrl(host)}:${port}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getDefaultHubPort() {
|
|
93
|
+
const envPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
|
|
94
|
+
return Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : 27888;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getDefaultHubUrl() {
|
|
98
|
+
return `${buildHubBaseUrl("127.0.0.1", getDefaultHubPort())}/mcp`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getDefaultHubBase() {
|
|
102
|
+
return getDefaultHubUrl().replace(/\/mcp$/, "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeLoopbackHost(host) {
|
|
106
|
+
if (typeof host !== "string") return "127.0.0.1";
|
|
107
|
+
const candidate = host.trim();
|
|
108
|
+
return LOOPBACK_HOSTS.has(candidate) ? candidate : "127.0.0.1";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function probeHubStatus(host, port, timeoutMs = 1500) {
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(`${buildHubBaseUrl(host, port)}/status`, {
|
|
114
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
115
|
+
});
|
|
116
|
+
if (!res.ok) return null;
|
|
117
|
+
const data = await res.json();
|
|
118
|
+
return data?.hub ? data : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function getHubInfo() {
|
|
125
|
+
const probePort = getDefaultHubPort();
|
|
126
|
+
|
|
127
|
+
if (existsSync(HUB_PID_FILE)) {
|
|
128
|
+
try {
|
|
129
|
+
const raw = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
130
|
+
const pid = Number(raw?.pid);
|
|
131
|
+
if (!Number.isFinite(pid) || pid <= 0) throw new Error("invalid pid");
|
|
132
|
+
process.kill(pid, 0); // 프로세스 생존 확인
|
|
133
|
+
const host = normalizeLoopbackHost(raw?.host);
|
|
134
|
+
const port = Number(raw.port) || 27888;
|
|
135
|
+
const status = await probeHubStatus(host, port, 1200);
|
|
136
|
+
if (!status) {
|
|
137
|
+
// transient timeout/응답 지연은 stale로 단정하지 않고 기존 PID 정보를 유지한다.
|
|
138
|
+
return {
|
|
139
|
+
...raw,
|
|
140
|
+
pid,
|
|
141
|
+
host,
|
|
142
|
+
port,
|
|
143
|
+
url: `${buildHubBaseUrl(host, port)}/mcp`,
|
|
144
|
+
degraded: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
...raw,
|
|
149
|
+
pid,
|
|
150
|
+
host,
|
|
151
|
+
port,
|
|
152
|
+
url: `${buildHubBaseUrl(host, port)}/mcp`,
|
|
153
|
+
};
|
|
154
|
+
} catch {
|
|
155
|
+
try { unlinkSync(HUB_PID_FILE); } catch {}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// PID 파일이 없거나 stale인 경우에도 실제 Hub가 떠 있으면 재사용
|
|
160
|
+
const candidates = Array.from(new Set([probePort, 27888]));
|
|
161
|
+
for (const portCandidate of candidates) {
|
|
162
|
+
const data = await probeHubStatus("127.0.0.1", portCandidate, 1200);
|
|
163
|
+
if (!data) continue;
|
|
164
|
+
const port = Number(data.port) || portCandidate;
|
|
165
|
+
const pid = Number(data.pid);
|
|
166
|
+
const recovered = {
|
|
167
|
+
pid: Number.isFinite(pid) ? pid : null,
|
|
168
|
+
host: "127.0.0.1",
|
|
169
|
+
port,
|
|
170
|
+
url: `${buildHubBaseUrl("127.0.0.1", port)}/mcp`,
|
|
171
|
+
discovered: true,
|
|
172
|
+
};
|
|
173
|
+
if (Number.isFinite(recovered.pid) && recovered.pid > 0) {
|
|
174
|
+
try {
|
|
175
|
+
mkdirSync(HUB_PID_DIR, { recursive: true });
|
|
176
|
+
writeFileSync(HUB_PID_FILE, JSON.stringify({
|
|
177
|
+
pid: recovered.pid,
|
|
178
|
+
port: recovered.port,
|
|
179
|
+
host: recovered.host,
|
|
180
|
+
url: recovered.url,
|
|
181
|
+
started: Date.now(),
|
|
182
|
+
}));
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
return recovered;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function startHubDaemon() {
|
|
191
|
+
const serverPath = join(PKG_ROOT, "hub", "server.mjs");
|
|
192
|
+
if (!existsSync(serverPath)) {
|
|
193
|
+
fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음");
|
|
90
194
|
return null;
|
|
91
195
|
}
|
|
92
196
|
|
|
93
|
-
const child = spawn(process.execPath, [serverPath], {
|
|
94
|
-
env: { ...process.env },
|
|
95
|
-
stdio: "ignore",
|
|
96
|
-
detached: true,
|
|
97
|
-
});
|
|
98
|
-
child.unref();
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
197
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
198
|
+
env: { ...process.env },
|
|
199
|
+
stdio: "ignore",
|
|
200
|
+
detached: true,
|
|
201
|
+
});
|
|
202
|
+
child.unref();
|
|
203
|
+
|
|
204
|
+
const expectedPort = getDefaultHubPort();
|
|
205
|
+
|
|
206
|
+
// Hub 상태 확인 (최대 3초 대기)
|
|
207
|
+
const deadline = Date.now() + 3000;
|
|
208
|
+
while (Date.now() < deadline) {
|
|
209
|
+
const info = await getHubInfo();
|
|
210
|
+
if (info && info.port === expectedPort) return info;
|
|
211
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
110
215
|
|
|
111
216
|
// ── 인자 파싱 ──
|
|
112
217
|
|
|
@@ -316,8 +421,8 @@ function resolveMember(state, selector) {
|
|
|
316
421
|
return null;
|
|
317
422
|
}
|
|
318
423
|
|
|
319
|
-
async function publishLeadControl(state, targetMember, command, reason = "") {
|
|
320
|
-
const hubBase = (state?.hubUrl ||
|
|
424
|
+
async function publishLeadControl(state, targetMember, command, reason = "") {
|
|
425
|
+
const hubBase = (state?.hubUrl || getDefaultHubUrl()).replace(/\/mcp$/, "");
|
|
321
426
|
const leadAgent = (state?.members || []).find((m) => m.role === "lead")?.agentId || "lead";
|
|
322
427
|
|
|
323
428
|
const payload = {
|
|
@@ -351,8 +456,8 @@ function isWtMode(state) {
|
|
|
351
456
|
return state?.teammateMode === "wt";
|
|
352
457
|
}
|
|
353
458
|
|
|
354
|
-
function isTeamAlive(state) {
|
|
355
|
-
if (!state) return false;
|
|
459
|
+
function isTeamAlive(state) {
|
|
460
|
+
if (!state) return false;
|
|
356
461
|
if (isNativeMode(state)) {
|
|
357
462
|
try {
|
|
358
463
|
process.kill(state.native.supervisorPid, 0);
|
|
@@ -361,12 +466,14 @@ function isTeamAlive(state) {
|
|
|
361
466
|
return false;
|
|
362
467
|
}
|
|
363
468
|
}
|
|
364
|
-
if (isWtMode(state)) {
|
|
365
|
-
// WT pane 상태를 신뢰성 있게 조회할 API가
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
469
|
+
if (isWtMode(state)) {
|
|
470
|
+
// WT pane 상태를 신뢰성 있게 조회할 API가 없어, WT_SESSION은 힌트로만 사용한다.
|
|
471
|
+
if (!hasWindowsTerminal()) return false;
|
|
472
|
+
if (hasWindowsTerminalSession()) return true;
|
|
473
|
+
return Array.isArray(state.members) && state.members.length > 0;
|
|
474
|
+
}
|
|
475
|
+
return sessionExists(state.sessionName);
|
|
476
|
+
}
|
|
370
477
|
|
|
371
478
|
async function nativeRequest(state, path, body = {}) {
|
|
372
479
|
if (!isNativeMode(state)) return null;
|
|
@@ -464,7 +571,7 @@ async function startNativeSupervisor({ sessionId, task, lead, agents, subtasks,
|
|
|
464
571
|
|
|
465
572
|
// ── 서브커맨드 ──
|
|
466
573
|
|
|
467
|
-
async function teamStart() {
|
|
574
|
+
async function teamStart() {
|
|
468
575
|
const { agents, lead, layout, teammateMode, task } = parseTeamArgs();
|
|
469
576
|
if (!task) {
|
|
470
577
|
console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET}\n`);
|
|
@@ -477,10 +584,10 @@ async function teamStart() {
|
|
|
477
584
|
|
|
478
585
|
console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET}\n`);
|
|
479
586
|
|
|
480
|
-
let hub = getHubInfo();
|
|
481
|
-
if (!hub) {
|
|
482
|
-
process.stdout.write(" Hub 시작 중...");
|
|
483
|
-
hub = startHubDaemon();
|
|
587
|
+
let hub = await getHubInfo();
|
|
588
|
+
if (!hub) {
|
|
589
|
+
process.stdout.write(" Hub 시작 중...");
|
|
590
|
+
hub = await startHubDaemon();
|
|
484
591
|
if (hub) {
|
|
485
592
|
console.log(` ${GREEN}✓${RESET}`);
|
|
486
593
|
} else {
|
|
@@ -493,7 +600,7 @@ async function teamStart() {
|
|
|
493
600
|
|
|
494
601
|
const sessionId = `tfx-team-${Date.now().toString(36).slice(-4)}`;
|
|
495
602
|
const subtasks = decomposeTask(task, agents.length);
|
|
496
|
-
const hubUrl = hub?.url ||
|
|
603
|
+
const hubUrl = hub?.url || getDefaultHubUrl();
|
|
497
604
|
let effectiveTeammateMode = teammateMode;
|
|
498
605
|
|
|
499
606
|
if (teammateMode === "wt") {
|
|
@@ -751,17 +858,20 @@ async function teamStart() {
|
|
|
751
858
|
tasks,
|
|
752
859
|
});
|
|
753
860
|
|
|
754
|
-
const
|
|
861
|
+
const profilePrefix = TEAM_PROFILE === "team" ? "" : `TFX_TEAM_PROFILE=${TEAM_PROFILE} `;
|
|
862
|
+
const taskListCommand = `${profilePrefix}${process.execPath} ${join(PKG_ROOT, "bin", "triflux.mjs")} team tasks`;
|
|
755
863
|
configureTeammateKeybindings(sessionId, {
|
|
756
864
|
inProcess: false,
|
|
757
865
|
taskListCommand,
|
|
758
866
|
});
|
|
759
867
|
|
|
760
|
-
console.log(`\n ${GREEN}${BOLD}팀 세션 준비 완료${RESET}`);
|
|
761
|
-
console.log(` ${DIM}Shift+Down: 다음 팀메이트 전환${RESET}`);
|
|
762
|
-
console.log(` ${DIM}
|
|
763
|
-
console.log(` ${DIM}
|
|
764
|
-
console.log(` ${DIM}Ctrl+
|
|
868
|
+
console.log(`\n ${GREEN}${BOLD}팀 세션 준비 완료${RESET}`);
|
|
869
|
+
console.log(` ${DIM}Shift+Down: 다음 팀메이트 전환${RESET}`);
|
|
870
|
+
console.log(` ${DIM}Shift+Tab / Shift+Left: 이전 팀메이트 전환${RESET}`);
|
|
871
|
+
console.log(` ${DIM}Escape: 현재 팀메이트 인터럽트${RESET}`);
|
|
872
|
+
console.log(` ${DIM}Ctrl+T: 태스크 목록${RESET}`);
|
|
873
|
+
console.log(` ${DIM}참고: 일부 터미널/호스트에서 Shift+Up 전달이 제한될 수 있음${RESET}`);
|
|
874
|
+
console.log(` ${DIM}Ctrl+B → D: 세션 분리 (백그라운드)${RESET}\n`);
|
|
765
875
|
|
|
766
876
|
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
767
877
|
attachSession(sessionId);
|
|
@@ -785,10 +895,13 @@ async function teamStatus() {
|
|
|
785
895
|
console.log(`\n ${AMBER}${BOLD}⬡ tfx team${RESET} ${status}\n`);
|
|
786
896
|
console.log(` 세션: ${state.sessionName}`);
|
|
787
897
|
console.log(` 모드: ${state.teammateMode || "tmux"}`);
|
|
788
|
-
console.log(` 리드: ${state.lead || "claude"}`);
|
|
789
|
-
console.log(` 워커: ${(state.agents || []).join(", ")}`);
|
|
790
|
-
console.log(` Uptime: ${uptime}`);
|
|
791
|
-
console.log(` 태스크: ${(state.tasks || []).length}`);
|
|
898
|
+
console.log(` 리드: ${state.lead || "claude"}`);
|
|
899
|
+
console.log(` 워커: ${(state.agents || []).join(", ")}`);
|
|
900
|
+
console.log(` Uptime: ${uptime}`);
|
|
901
|
+
console.log(` 태스크: ${(state.tasks || []).length}`);
|
|
902
|
+
if (isWtMode(state) && !hasWindowsTerminalSession()) {
|
|
903
|
+
console.log(` ${DIM}WT_SESSION 미감지: 생존성은 heuristics로 판정됨${RESET}`);
|
|
904
|
+
}
|
|
792
905
|
|
|
793
906
|
const members = state.members || [];
|
|
794
907
|
if (members.length) {
|
|
@@ -841,8 +954,8 @@ async function teamStatus() {
|
|
|
841
954
|
* @param {object} state — team-state.json
|
|
842
955
|
* @returns {Promise<Array>}
|
|
843
956
|
*/
|
|
844
|
-
async function fetchHubTaskList(state) {
|
|
845
|
-
const hubBase = (state?.hubUrl ||
|
|
957
|
+
async function fetchHubTaskList(state) {
|
|
958
|
+
const hubBase = (state?.hubUrl || getDefaultHubUrl()).replace(/\/mcp$/, "");
|
|
846
959
|
// teamName: native 모드는 state에 저장된 팀 이름, SKILL.md 모드는 세션 이름 기반
|
|
847
960
|
const teamName = state?.native?.teamName || state?.sessionName || null;
|
|
848
961
|
if (!teamName) return [];
|
|
@@ -952,7 +1065,7 @@ async function teamDebug() {
|
|
|
952
1065
|
const linesIdx = process.argv.findIndex((a) => a === "--lines" || a === "-n");
|
|
953
1066
|
const lines = linesIdx !== -1 ? Math.max(3, parseInt(process.argv[linesIdx + 1] || "20", 10) || 20) : 20;
|
|
954
1067
|
const mux = detectMultiplexer() || "none";
|
|
955
|
-
const hub = getHubInfo();
|
|
1068
|
+
const hub = await getHubInfo();
|
|
956
1069
|
|
|
957
1070
|
console.log(`\n ${AMBER}${BOLD}⬡ Team Debug${RESET}\n`);
|
|
958
1071
|
console.log(` platform: ${process.platform}`);
|
|
@@ -970,9 +1083,10 @@ async function teamDebug() {
|
|
|
970
1083
|
return;
|
|
971
1084
|
}
|
|
972
1085
|
|
|
973
|
-
console.log(`\n ${BOLD}state${RESET}`);
|
|
974
|
-
console.log(` session: ${state.sessionName}`);
|
|
975
|
-
console.log(`
|
|
1086
|
+
console.log(`\n ${BOLD}state${RESET}`);
|
|
1087
|
+
console.log(` session: ${state.sessionName}`);
|
|
1088
|
+
console.log(` profile: ${state.profile || TEAM_PROFILE}`);
|
|
1089
|
+
console.log(` mode: ${state.teammateMode || "tmux"}`);
|
|
976
1090
|
console.log(` lead: ${state.lead}`);
|
|
977
1091
|
console.log(` agents: ${(state.agents || []).join(", ")}`);
|
|
978
1092
|
console.log(` alive: ${isTeamAlive(state) ? "yes" : "no"}`);
|
|
@@ -1336,13 +1450,15 @@ function teamHelp() {
|
|
|
1336
1450
|
${WHITE}tfx team kill${RESET} ${GRAY}모든 팀 세션 강제 종료${RESET}
|
|
1337
1451
|
${WHITE}tfx team list${RESET} ${GRAY}활성 세션 목록${RESET}
|
|
1338
1452
|
|
|
1339
|
-
${BOLD}키 조작(Claude teammate 스타일, tmux 모드)${RESET}
|
|
1340
|
-
${WHITE}Shift+Down${RESET} ${GRAY}다음 팀메이트${RESET}
|
|
1341
|
-
${WHITE}Shift+
|
|
1342
|
-
${WHITE}
|
|
1343
|
-
${WHITE}
|
|
1344
|
-
|
|
1345
|
-
}
|
|
1453
|
+
${BOLD}키 조작(Claude teammate 스타일, tmux 모드)${RESET}
|
|
1454
|
+
${WHITE}Shift+Down${RESET} ${GRAY}다음 팀메이트${RESET}
|
|
1455
|
+
${WHITE}Shift+Tab${RESET} ${GRAY}이전 팀메이트 (권장)${RESET}
|
|
1456
|
+
${WHITE}Shift+Left${RESET} ${GRAY}이전 팀메이트 (대체)${RESET}
|
|
1457
|
+
${WHITE}Shift+Up${RESET} ${GRAY}이전 팀메이트 (환경 따라 미동작 가능)${RESET}
|
|
1458
|
+
${WHITE}Escape${RESET} ${GRAY}현재 팀메이트 인터럽트${RESET}
|
|
1459
|
+
${WHITE}Ctrl+T${RESET} ${GRAY}태스크 목록 토글${RESET}
|
|
1460
|
+
`);
|
|
1461
|
+
}
|
|
1346
1462
|
|
|
1347
1463
|
// ── 메인 진입점 ──
|
|
1348
1464
|
|
|
@@ -1350,8 +1466,9 @@ function teamHelp() {
|
|
|
1350
1466
|
* tfx team 서브커맨드 라우터
|
|
1351
1467
|
* bin/triflux.mjs에서 호출
|
|
1352
1468
|
*/
|
|
1353
|
-
export async function cmdTeam() {
|
|
1354
|
-
const
|
|
1469
|
+
export async function cmdTeam() {
|
|
1470
|
+
const rawSub = process.argv[3];
|
|
1471
|
+
const sub = typeof rawSub === "string" ? rawSub.toLowerCase() : rawSub;
|
|
1355
1472
|
|
|
1356
1473
|
switch (sub) {
|
|
1357
1474
|
case "status": return teamStatus();
|
|
@@ -1370,13 +1487,13 @@ export async function cmdTeam() {
|
|
|
1370
1487
|
case "--help":
|
|
1371
1488
|
case "-h":
|
|
1372
1489
|
return teamHelp();
|
|
1373
|
-
case undefined:
|
|
1374
|
-
return teamHelp();
|
|
1375
|
-
default:
|
|
1376
|
-
// 서브커맨드가 아니면 작업 문자열로 간주
|
|
1377
|
-
if (!sub.startsWith("-") && TEAM_SUBCOMMANDS.has(sub)) {
|
|
1378
|
-
return teamHelp();
|
|
1379
|
-
}
|
|
1380
|
-
return teamStart();
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1490
|
+
case undefined:
|
|
1491
|
+
return teamHelp();
|
|
1492
|
+
default:
|
|
1493
|
+
// 서브커맨드가 아니면 작업 문자열로 간주
|
|
1494
|
+
if (typeof sub === "string" && !sub.startsWith("-") && TEAM_SUBCOMMANDS.has(sub)) {
|
|
1495
|
+
return teamHelp();
|
|
1496
|
+
}
|
|
1497
|
+
return teamStart();
|
|
1498
|
+
}
|
|
1499
|
+
}
|
package/hub/team/session.mjs
CHANGED
|
@@ -393,33 +393,49 @@ export function focusPane(target, opts = {}) {
|
|
|
393
393
|
}
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
-
/**
|
|
397
|
-
* 팀메이트 조작 키 바인딩 설정
|
|
398
|
-
* - Shift+Down: 다음 팀메이트
|
|
399
|
-
* - Shift+Up: 이전 팀메이트
|
|
400
|
-
* -
|
|
401
|
-
* -
|
|
402
|
-
*
|
|
396
|
+
/**
|
|
397
|
+
* 팀메이트 조작 키 바인딩 설정
|
|
398
|
+
* - Shift+Down: 다음 팀메이트
|
|
399
|
+
* - Shift+Up: 이전 팀메이트
|
|
400
|
+
* - Shift+Left / Shift+Tab: 이전 팀메이트 대체 키
|
|
401
|
+
* - Shift+Right: 다음 팀메이트 대체 키
|
|
402
|
+
* - Escape: 현재 팀메이트 인터럽트(C-c)
|
|
403
|
+
* - Ctrl+T: 태스크 목록 표시
|
|
404
|
+
* @param {string} sessionName
|
|
403
405
|
* @param {object} opts
|
|
404
406
|
* @param {boolean} opts.inProcess
|
|
405
407
|
* @param {string} opts.taskListCommand
|
|
406
408
|
*/
|
|
407
|
-
export function configureTeammateKeybindings(sessionName, opts = {}) {
|
|
408
|
-
const { inProcess = false, taskListCommand = "" } = opts;
|
|
409
|
-
const cond = `#{==:#{session_name},${sessionName}}`;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
409
|
+
export function configureTeammateKeybindings(sessionName, opts = {}) {
|
|
410
|
+
const { inProcess = false, taskListCommand = "" } = opts;
|
|
411
|
+
const cond = `#{==:#{session_name},${sessionName}}`;
|
|
412
|
+
|
|
413
|
+
// Shift+Up이 터미널/호스트 조합에 따라 전달되지 않는 경우가 있어
|
|
414
|
+
// 좌/우/Shift+Tab 대체 키를 함께 바인딩한다.
|
|
415
|
+
const bindNext = inProcess
|
|
416
|
+
? `'select-pane -t :.+ \\; resize-pane -Z'`
|
|
417
|
+
: `'select-pane -t :.+'`;
|
|
418
|
+
const bindPrev = inProcess
|
|
419
|
+
? `'select-pane -t :.- \\; resize-pane -Z'`
|
|
420
|
+
: `'select-pane -t :.-'`;
|
|
421
|
+
|
|
422
|
+
if (inProcess) {
|
|
423
|
+
// 단일 뷰(zoom) 상태에서 팀메이트 순환
|
|
424
|
+
tmux(`bind-key -T root -n S-Down if-shell -F '${cond}' ${bindNext} 'send-keys S-Down'`);
|
|
425
|
+
tmux(`bind-key -T root -n S-Up if-shell -F '${cond}' ${bindPrev} 'send-keys S-Up'`);
|
|
426
|
+
} else {
|
|
427
|
+
// 분할 뷰에서 팀메이트 순환
|
|
428
|
+
tmux(`bind-key -T root -n S-Down if-shell -F '${cond}' ${bindNext} 'send-keys S-Down'`);
|
|
429
|
+
tmux(`bind-key -T root -n S-Up if-shell -F '${cond}' ${bindPrev} 'send-keys S-Up'`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 대체 키: 일부 환경에서 S-Up이 누락될 때 사용
|
|
433
|
+
tmux(`bind-key -T root -n S-Right if-shell -F '${cond}' ${bindNext} 'send-keys S-Right'`);
|
|
434
|
+
tmux(`bind-key -T root -n S-Left if-shell -F '${cond}' ${bindPrev} 'send-keys S-Left'`);
|
|
435
|
+
tmux(`bind-key -T root -n BTab if-shell -F '${cond}' ${bindPrev} 'send-keys BTab'`);
|
|
436
|
+
|
|
437
|
+
// 현재 활성 pane 인터럽트
|
|
438
|
+
tmux(`bind-key -T root -n Escape if-shell -F '${cond}' 'send-keys C-c' 'send-keys Escape'`);
|
|
423
439
|
|
|
424
440
|
// 태스크 목록 토글 (tmux 3.2+ popup 우선, 실패 시 안내 메시지)
|
|
425
441
|
if (taskListCommand) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart 훅에서 호출되는 Hub 보장 스크립트.
|
|
3
|
+
// - /status 기반 헬스체크
|
|
4
|
+
// - 비정상 시 Hub를 detached로 기동
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "fs";
|
|
7
|
+
import { join, dirname } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { spawn } from "child_process";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
|
|
12
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
13
|
+
const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
14
|
+
const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
15
|
+
|
|
16
|
+
function formatHostForUrl(host) {
|
|
17
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildHubBaseUrl(host, port) {
|
|
21
|
+
return `http://${formatHostForUrl(host)}:${port}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveHubTarget() {
|
|
25
|
+
const envPortRaw = Number(process.env.TFX_HUB_PORT || "");
|
|
26
|
+
const envPort = Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : null;
|
|
27
|
+
const target = {
|
|
28
|
+
host: "127.0.0.1",
|
|
29
|
+
port: envPort || 27888,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (existsSync(HUB_PID_FILE)) {
|
|
33
|
+
try {
|
|
34
|
+
const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
35
|
+
if (!envPort) {
|
|
36
|
+
const pidPort = Number(info?.port);
|
|
37
|
+
if (Number.isFinite(pidPort) && pidPort > 0) target.port = pidPort;
|
|
38
|
+
}
|
|
39
|
+
if (typeof info?.host === "string") {
|
|
40
|
+
const host = info.host.trim();
|
|
41
|
+
if (LOOPBACK_HOSTS.has(host)) target.host = host;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore parse errors and use env/default
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return target;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function isHubHealthy(host, port) {
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${buildHubBaseUrl(host, port)}/status`, {
|
|
54
|
+
signal: AbortSignal.timeout(1000),
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) return false;
|
|
57
|
+
const data = await res.json();
|
|
58
|
+
return data?.hub?.state === "healthy";
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function startHubDetached(port) {
|
|
65
|
+
const serverPath = join(PLUGIN_ROOT, "hub", "server.mjs");
|
|
66
|
+
if (!existsSync(serverPath)) return;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
70
|
+
env: { ...process.env, TFX_HUB_PORT: String(port) },
|
|
71
|
+
detached: true,
|
|
72
|
+
stdio: "ignore",
|
|
73
|
+
});
|
|
74
|
+
child.unref();
|
|
75
|
+
} catch {
|
|
76
|
+
// best effort
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { host, port } = resolveHubTarget();
|
|
81
|
+
if (!(await isHubHealthy(host, port))) {
|
|
82
|
+
startHubDetached(port);
|
|
83
|
+
}
|
package/scripts/setup.mjs
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
// - hud-qos-status.mjs를 ~/.claude/hud/에 동기화
|
|
5
5
|
// - skills/를 ~/.claude/skills/에 동기화
|
|
6
6
|
|
|
7
|
-
import { copyFileSync, mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, chmodSync, unlinkSync } from "fs";
|
|
8
|
-
import { join, dirname } from "path";
|
|
9
|
-
import { homedir } from "os";
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
import { copyFileSync, mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, chmodSync, unlinkSync } from "fs";
|
|
8
|
+
import { join, dirname } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { spawn } from "child_process";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
|
|
13
|
+
const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
12
14
|
const CLAUDE_DIR = join(homedir(), ".claude");
|
|
13
15
|
const CODEX_DIR = join(homedir(), ".codex");
|
|
14
16
|
const CODEX_CONFIG_PATH = join(CODEX_DIR, "config.toml");
|
|
@@ -50,15 +52,24 @@ const SYNC_MAP = [
|
|
|
50
52
|
},
|
|
51
53
|
];
|
|
52
54
|
|
|
53
|
-
function getVersion(filePath) {
|
|
54
|
-
try {
|
|
55
|
-
const content = readFileSync(filePath, "utf8");
|
|
56
|
-
const match = content.match(/VERSION\s*=\s*"([^"]+)"/);
|
|
57
|
-
return match ? match[1] : null;
|
|
58
|
-
} catch {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
55
|
+
function getVersion(filePath) {
|
|
56
|
+
try {
|
|
57
|
+
const content = readFileSync(filePath, "utf8");
|
|
58
|
+
const match = content.match(/VERSION\s*=\s*"([^"]+)"/);
|
|
59
|
+
return match ? match[1] : null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function shouldSyncTextFile(src, dst) {
|
|
66
|
+
if (!existsSync(dst)) return true;
|
|
67
|
+
try {
|
|
68
|
+
return readFileSync(src, "utf8") !== readFileSync(dst, "utf8");
|
|
69
|
+
} catch {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
62
73
|
|
|
63
74
|
function escapeRegExp(value) {
|
|
64
75
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -113,15 +124,13 @@ for (const { src, dst, label } of SYNC_MAP) {
|
|
|
113
124
|
copyFileSync(src, dst);
|
|
114
125
|
try { chmodSync(dst, 0o755); } catch {}
|
|
115
126
|
synced++;
|
|
116
|
-
} else {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
}
|
|
127
|
+
} else {
|
|
128
|
+
if (shouldSyncTextFile(src, dst)) {
|
|
129
|
+
copyFileSync(src, dst);
|
|
130
|
+
try { chmodSync(dst, 0o755); } catch {}
|
|
131
|
+
synced++;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
125
134
|
}
|
|
126
135
|
|
|
127
136
|
// ── 스킬 동기화 ──
|
|
@@ -291,18 +300,36 @@ if (codexProfilesAdded > 0) {
|
|
|
291
300
|
synced++;
|
|
292
301
|
}
|
|
293
302
|
|
|
294
|
-
// ── MCP 인벤토리 백그라운드 갱신 ──
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
if (existsSync(mcpCheck)) {
|
|
300
|
-
const child = spawn(process.execPath, [mcpCheck], {
|
|
303
|
+
// ── MCP 인벤토리 백그라운드 갱신 ──
|
|
304
|
+
|
|
305
|
+
const mcpCheck = join(PLUGIN_ROOT, "scripts", "mcp-check.mjs");
|
|
306
|
+
if (existsSync(mcpCheck)) {
|
|
307
|
+
const child = spawn(process.execPath, [mcpCheck], {
|
|
301
308
|
detached: true,
|
|
302
309
|
stdio: "ignore",
|
|
303
310
|
});
|
|
304
|
-
child.unref(); // 부모 프로세스와 분리 — 비동기 실행
|
|
305
|
-
}
|
|
311
|
+
child.unref(); // 부모 프로세스와 분리 — 비동기 실행
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Hub 헬스체크 + 자동 기동 (세션 시작 백그라운드) ──
|
|
315
|
+
// setup 훅이 포그라운드 지연을 만들지 않도록 별도 detached 프로세스로 처리한다.
|
|
316
|
+
const hubEnsure = join(PLUGIN_ROOT, "scripts", "hub-ensure.mjs");
|
|
317
|
+
const isPostinstall = process.env.npm_lifecycle_event === "postinstall";
|
|
318
|
+
const isCi = /^(1|true)$/i.test(process.env.CI || "");
|
|
319
|
+
const disableHubAutostart = process.env.TFX_DISABLE_HUB_AUTOSTART === "1";
|
|
320
|
+
|
|
321
|
+
if (!isPostinstall && !isCi && !disableHubAutostart && existsSync(hubEnsure)) {
|
|
322
|
+
try {
|
|
323
|
+
const child = spawn(process.execPath, [hubEnsure], {
|
|
324
|
+
env: process.env,
|
|
325
|
+
detached: true,
|
|
326
|
+
stdio: "ignore",
|
|
327
|
+
});
|
|
328
|
+
child.unref();
|
|
329
|
+
} catch {
|
|
330
|
+
// best effort: 실패해도 setup 흐름은 지속
|
|
331
|
+
}
|
|
332
|
+
}
|
|
306
333
|
|
|
307
334
|
// ── postinstall 배너 (npm install 시에만 출력) ──
|
|
308
335
|
|
|
@@ -330,11 +357,12 @@ ${B}╚════════════════════════
|
|
|
330
357
|
${G}✓${R} ${synced > 0 ? synced + " files synced" : "all files up to date"}
|
|
331
358
|
${G}✓${R} HUD statusLine → settings.json
|
|
332
359
|
|
|
333
|
-
${B}Commands:${R}
|
|
334
|
-
${C}triflux${R} setup 파일 동기화 + HUD 설정
|
|
335
|
-
${C}triflux${R} doctor CLI 진단 (Codex/Gemini 확인)
|
|
336
|
-
${C}triflux${R} list 설치된 스킬 목록
|
|
337
|
-
${C}triflux${R} update 최신 버전으로 업데이트
|
|
360
|
+
${B}Commands:${R}
|
|
361
|
+
${C}triflux${R} setup 파일 동기화 + HUD 설정
|
|
362
|
+
${C}triflux${R} doctor CLI 진단 (Codex/Gemini 확인)
|
|
363
|
+
${C}triflux${R} list 설치된 스킬 목록
|
|
364
|
+
${C}triflux${R} update 최신 안정 버전으로 업데이트
|
|
365
|
+
${C}triflux${R} update --dev dev 채널로 업데이트 (${D}dev 별칭 지원${R})
|
|
338
366
|
|
|
339
367
|
${B}Shortcuts:${R}
|
|
340
368
|
${C}tfx${R} triflux 축약
|
package/skills/tfx-team/SKILL.md
CHANGED
|
@@ -35,6 +35,22 @@ argument-hint: '"작업 설명" | --agents codex,gemini "작업" | --tmux "작
|
|
|
35
35
|
|
|
36
36
|
## 실행 워크플로우
|
|
37
37
|
|
|
38
|
+
### Phase 0: 사전 점검 정책 (출력 최소화)
|
|
39
|
+
|
|
40
|
+
세션 시작 전 점검은 **리드 포그라운드에서 단계별로 장황하게 출력하지 않는다.**
|
|
41
|
+
|
|
42
|
+
- 원칙:
|
|
43
|
+
- 사전 점검은 워커 생성과 **비동기 병렬**로 수행한다.
|
|
44
|
+
- 사용자가 요청하지 않으면 `Searched for ...`, 개별 `Bash(...)` 로그를 전면 보고하지 않는다.
|
|
45
|
+
- 리드에는 요약 한 줄만 노출한다. 예: `preflight: ok (route/hub)`
|
|
46
|
+
- Hub 점검은 `/status`를 기준으로 한다 (`/health` 단독 판정 금지).
|
|
47
|
+
- 권장 체크 예시:
|
|
48
|
+
- `curl -sf http://127.0.0.1:27888/status >/dev/null && echo "Hub: OK" || echo "Hub: NOT RUNNING"`
|
|
49
|
+
- 실패 시에만 상세를 노출한다:
|
|
50
|
+
- `tfx-route.sh` 없음
|
|
51
|
+
- Hub 비정상/미기동
|
|
52
|
+
- 필수 CLI 미설치
|
|
53
|
+
|
|
38
54
|
### Phase 1: 입력 파싱
|
|
39
55
|
|
|
40
56
|
```
|
|
@@ -192,7 +208,8 @@ Agent({
|
|
|
192
208
|
```
|
|
193
209
|
"팀 '{teamName}' 생성 완료.
|
|
194
210
|
Codex/Gemini 워커가 슬림 래퍼 Agent로 네비게이션에 등록되었습니다.
|
|
195
|
-
Shift+Down으로 워커
|
|
211
|
+
Shift+Down으로 다음 워커, Shift+Tab/Shift+Left로 이전 워커 전환이 가능합니다.
|
|
212
|
+
(Shift+Up은 일부 환경에서 동작하지 않을 수 있음)"
|
|
196
213
|
```
|
|
197
214
|
|
|
198
215
|
### Phase 4: 결과 수집 (truth source = team_task_list)
|
|
@@ -262,6 +279,7 @@ Bash("node {PKG_ROOT}/bin/triflux.mjs team --no-attach --agents {agents.join(','
|
|
|
262
279
|
- **codex/gemini CLI** — 해당 에이전트 사용 시
|
|
263
280
|
- **tfx setup** — tfx-route.sh 동기화 + AGENT_TEAMS 자동 설정 (사전 실행 권장)
|
|
264
281
|
- **Hub bridge 활성 상태** — 기본 `http://127.0.0.1:27888` (`/bridge/team/*`, `/bridge/result` 사용)
|
|
282
|
+
- **출력 정책** — preflight는 비동기/요약 출력이 기본이며, 실패 시에만 상세 출력
|
|
265
283
|
|
|
266
284
|
## 에러 처리
|
|
267
285
|
|