triflux 3.2.0-dev.5 → 3.2.0-dev.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/triflux.mjs +138 -62
- 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 +82 -0
- package/scripts/setup.mjs +31 -12
- package/skills/tfx-team/SKILL.md +19 -1
package/bin/triflux.mjs
CHANGED
|
@@ -961,27 +961,31 @@ async function cmdCodexTeam() {
|
|
|
961
961
|
return;
|
|
962
962
|
}
|
|
963
963
|
|
|
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
|
-
|
|
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 normalizedArgs = isControl && args.length ? [sub, ...args.slice(1)] : args;
|
|
969
|
+
const inject = [];
|
|
970
|
+
if (!isControl && !hasLead) inject.push("--lead", "codex");
|
|
971
|
+
if (!isControl && !hasAgents) inject.push("--agents", "codex,codex");
|
|
972
|
+
if (!isControl && !hasLayout) inject.push("--layout", "1xN");
|
|
973
|
+
const forwarded = isControl ? normalizedArgs : [...inject, ...args];
|
|
974
|
+
|
|
975
|
+
const prevArgv = process.argv;
|
|
976
|
+
const prevProfile = process.env.TFX_TEAM_PROFILE;
|
|
977
|
+
process.env.TFX_TEAM_PROFILE = "codex-team";
|
|
978
|
+
const { pathToFileURL } = await import("node:url");
|
|
979
|
+
const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
|
|
980
|
+
process.argv = [prevArgv[0], prevArgv[1], "team", ...forwarded];
|
|
981
|
+
try {
|
|
982
|
+
await cmdTeam();
|
|
983
|
+
} finally {
|
|
984
|
+
process.argv = prevArgv;
|
|
985
|
+
if (typeof prevProfile === "string") process.env.TFX_TEAM_PROFILE = prevProfile;
|
|
986
|
+
else delete process.env.TFX_TEAM_PROFILE;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
985
989
|
|
|
986
990
|
// ── hub 서브커맨드 ──
|
|
987
991
|
|
|
@@ -1062,10 +1066,40 @@ function autoRegisterMcp(mcpUrl) {
|
|
|
1062
1066
|
} catch (e) { warn(`Claude 등록 실패: ${e.message}`); }
|
|
1063
1067
|
}
|
|
1064
1068
|
|
|
1065
|
-
function cmdHub() {
|
|
1066
|
-
const sub = process.argv[3] || "status";
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
+
async function cmdHub() {
|
|
1070
|
+
const sub = process.argv[3] || "status";
|
|
1071
|
+
const defaultPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
|
|
1072
|
+
const probePort = Number.isFinite(defaultPortRaw) && defaultPortRaw > 0 ? defaultPortRaw : 27888;
|
|
1073
|
+
const formatHostForUrl = (host) => host.includes(":") ? `[${host}]` : host;
|
|
1074
|
+
const probeHubStatus = async (host = "127.0.0.1", port = probePort, timeoutMs = 3000) => {
|
|
1075
|
+
try {
|
|
1076
|
+
const res = await fetch(`http://${formatHostForUrl(host)}:${port}/status`, {
|
|
1077
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1078
|
+
});
|
|
1079
|
+
if (!res.ok) return null;
|
|
1080
|
+
const data = await res.json();
|
|
1081
|
+
return data?.hub ? data : null;
|
|
1082
|
+
} catch {
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
const recoverPidFile = (statusData, defaultHost = "127.0.0.1") => {
|
|
1087
|
+
const pid = Number(statusData?.pid);
|
|
1088
|
+
const port = Number(statusData?.port) || probePort;
|
|
1089
|
+
if (!Number.isFinite(pid) || pid <= 0) return;
|
|
1090
|
+
try {
|
|
1091
|
+
mkdirSync(HUB_PID_DIR, { recursive: true });
|
|
1092
|
+
writeFileSync(HUB_PID_FILE, JSON.stringify({
|
|
1093
|
+
pid,
|
|
1094
|
+
port,
|
|
1095
|
+
host: defaultHost,
|
|
1096
|
+
url: `http://${formatHostForUrl(defaultHost)}:${port}/mcp`,
|
|
1097
|
+
started: Date.now(),
|
|
1098
|
+
}));
|
|
1099
|
+
} catch {}
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
switch (sub) {
|
|
1069
1103
|
case "start": {
|
|
1070
1104
|
// 이미 실행 중인지 확인
|
|
1071
1105
|
if (existsSync(HUB_PID_FILE)) {
|
|
@@ -1096,13 +1130,13 @@ function cmdHub() {
|
|
|
1096
1130
|
});
|
|
1097
1131
|
child.unref();
|
|
1098
1132
|
|
|
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
|
-
}
|
|
1133
|
+
// PID 파일 확인 (최대 3초 대기, 100ms 폴링)
|
|
1134
|
+
let started = false;
|
|
1135
|
+
const deadline = Date.now() + 3000;
|
|
1136
|
+
while (Date.now() < deadline) {
|
|
1137
|
+
if (existsSync(HUB_PID_FILE)) { started = true; break; }
|
|
1138
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1139
|
+
}
|
|
1106
1140
|
|
|
1107
1141
|
if (started) {
|
|
1108
1142
|
const hubInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
@@ -1121,11 +1155,20 @@ function cmdHub() {
|
|
|
1121
1155
|
break;
|
|
1122
1156
|
}
|
|
1123
1157
|
|
|
1124
|
-
case "stop": {
|
|
1125
|
-
if (!existsSync(HUB_PID_FILE)) {
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1158
|
+
case "stop": {
|
|
1159
|
+
if (!existsSync(HUB_PID_FILE)) {
|
|
1160
|
+
const probed = await probeHubStatus("127.0.0.1", probePort, 1500)
|
|
1161
|
+
|| (probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500));
|
|
1162
|
+
if (probed && Number.isFinite(Number(probed.pid))) {
|
|
1163
|
+
try {
|
|
1164
|
+
process.kill(Number(probed.pid), "SIGTERM");
|
|
1165
|
+
console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${probed.pid})${DIM} (probe)${RESET}\n`);
|
|
1166
|
+
return;
|
|
1167
|
+
} catch {}
|
|
1168
|
+
}
|
|
1169
|
+
console.log(`\n ${DIM}hub 미실행${RESET}\n`);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1129
1172
|
try {
|
|
1130
1173
|
const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
1131
1174
|
process.kill(info.pid, "SIGTERM");
|
|
@@ -1137,14 +1180,36 @@ function cmdHub() {
|
|
|
1137
1180
|
}
|
|
1138
1181
|
break;
|
|
1139
1182
|
}
|
|
1140
|
-
|
|
1141
|
-
case "status": {
|
|
1142
|
-
if (!existsSync(HUB_PID_FILE)) {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1183
|
+
|
|
1184
|
+
case "status": {
|
|
1185
|
+
if (!existsSync(HUB_PID_FILE)) {
|
|
1186
|
+
const probed = await probeHubStatus();
|
|
1187
|
+
if (!probed) {
|
|
1188
|
+
const fallback = probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500);
|
|
1189
|
+
if (fallback) {
|
|
1190
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(default port probe 성공)${RESET}`);
|
|
1191
|
+
console.log(` URL: http://127.0.0.1:${fallback.port || 27888}/mcp`);
|
|
1192
|
+
if (fallback.pid !== undefined) console.log(` PID: ${fallback.pid}`);
|
|
1193
|
+
if (fallback.hub?.state) console.log(` State: ${fallback.hub.state}`);
|
|
1194
|
+
if (fallback.sessions !== undefined) console.log(` Sessions: ${fallback.sessions}`);
|
|
1195
|
+
recoverPidFile(fallback, "127.0.0.1");
|
|
1196
|
+
console.log("");
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(pid file 없음 / probe 성공)${RESET}`);
|
|
1203
|
+
console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
|
|
1204
|
+
if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
|
|
1205
|
+
if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
|
|
1206
|
+
if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
|
|
1207
|
+
recoverPidFile(probed, "127.0.0.1");
|
|
1208
|
+
console.log("");
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
try {
|
|
1212
|
+
const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
1148
1213
|
process.kill(info.pid, 0); // 생존 확인
|
|
1149
1214
|
const uptime = Date.now() - info.started;
|
|
1150
1215
|
const uptimeStr = uptime < 60000 ? `${Math.round(uptime / 1000)}초`
|
|
@@ -1154,28 +1219,39 @@ function cmdHub() {
|
|
|
1154
1219
|
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`);
|
|
1155
1220
|
console.log(` URL: ${info.url}`);
|
|
1156
1221
|
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
|
-
}
|
|
1222
|
+
console.log(` Uptime: ${uptimeStr}`);
|
|
1223
|
+
|
|
1224
|
+
// HTTP 상태 조회 시도
|
|
1225
|
+
try {
|
|
1226
|
+
const host = typeof info.host === "string" ? info.host : "127.0.0.1";
|
|
1227
|
+
const port = Number(info.port) || probePort;
|
|
1228
|
+
const data = await probeHubStatus(host, port, 3000);
|
|
1229
|
+
if (data.hub) {
|
|
1230
|
+
console.log(` State: ${data.hub.state}`);
|
|
1231
|
+
}
|
|
1167
1232
|
if (data.sessions !== undefined) {
|
|
1168
1233
|
console.log(` Sessions: ${data.sessions}`);
|
|
1169
1234
|
}
|
|
1170
1235
|
} catch {}
|
|
1171
1236
|
|
|
1172
1237
|
console.log("");
|
|
1173
|
-
} catch {
|
|
1174
|
-
try { unlinkSync(HUB_PID_FILE); } catch {}
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1238
|
+
} catch {
|
|
1239
|
+
try { unlinkSync(HUB_PID_FILE); } catch {}
|
|
1240
|
+
const probed = await probeHubStatus();
|
|
1241
|
+
if (!probed) {
|
|
1242
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(stale PID 정리 후 probe 성공)${RESET}`);
|
|
1246
|
+
console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
|
|
1247
|
+
if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
|
|
1248
|
+
if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
|
|
1249
|
+
if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
|
|
1250
|
+
recoverPidFile(probed, "127.0.0.1");
|
|
1251
|
+
console.log("");
|
|
1252
|
+
}
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1179
1255
|
|
|
1180
1256
|
default:
|
|
1181
1257
|
console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
|
|
@@ -1200,7 +1276,7 @@ switch (cmd) {
|
|
|
1200
1276
|
}
|
|
1201
1277
|
case "update": cmdUpdate(); break;
|
|
1202
1278
|
case "list": case "ls": cmdList(); break;
|
|
1203
|
-
case "hub": cmdHub(); break;
|
|
1279
|
+
case "hub": await cmdHub(); break;
|
|
1204
1280
|
case "team": {
|
|
1205
1281
|
const { pathToFileURL } = await import("node:url");
|
|
1206
1282
|
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,82 @@
|
|
|
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
|
+
|
|
11
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
12
|
+
const PLUGIN_ROOT = dirname(dirname(new URL(import.meta.url).pathname)).replace(/^\/([A-Z]:)/, "$1");
|
|
13
|
+
const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
14
|
+
|
|
15
|
+
function formatHostForUrl(host) {
|
|
16
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildHubBaseUrl(host, port) {
|
|
20
|
+
return `http://${formatHostForUrl(host)}:${port}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveHubTarget() {
|
|
24
|
+
const envPortRaw = Number(process.env.TFX_HUB_PORT || "");
|
|
25
|
+
const envPort = Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : null;
|
|
26
|
+
const target = {
|
|
27
|
+
host: "127.0.0.1",
|
|
28
|
+
port: envPort || 27888,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
if (existsSync(HUB_PID_FILE)) {
|
|
32
|
+
try {
|
|
33
|
+
const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
34
|
+
if (!envPort) {
|
|
35
|
+
const pidPort = Number(info?.port);
|
|
36
|
+
if (Number.isFinite(pidPort) && pidPort > 0) target.port = pidPort;
|
|
37
|
+
}
|
|
38
|
+
if (typeof info?.host === "string") {
|
|
39
|
+
const host = info.host.trim();
|
|
40
|
+
if (LOOPBACK_HOSTS.has(host)) target.host = host;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// ignore parse errors and use env/default
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return target;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function isHubHealthy(host, port) {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(`${buildHubBaseUrl(host, port)}/status`, {
|
|
53
|
+
signal: AbortSignal.timeout(1000),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) return false;
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
return data?.hub?.state === "healthy";
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function startHubDetached(port) {
|
|
64
|
+
const serverPath = join(PLUGIN_ROOT, "hub", "server.mjs");
|
|
65
|
+
if (!existsSync(serverPath)) return;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
69
|
+
env: { ...process.env, TFX_HUB_PORT: String(port) },
|
|
70
|
+
detached: true,
|
|
71
|
+
stdio: "ignore",
|
|
72
|
+
});
|
|
73
|
+
child.unref();
|
|
74
|
+
} catch {
|
|
75
|
+
// best effort
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { host, port } = resolveHubTarget();
|
|
80
|
+
if (!(await isHubHealthy(host, port))) {
|
|
81
|
+
startHubDetached(port);
|
|
82
|
+
}
|
package/scripts/setup.mjs
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
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";
|
|
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";
|
|
10
11
|
|
|
11
12
|
const PLUGIN_ROOT = dirname(dirname(new URL(import.meta.url).pathname)).replace(/^\/([A-Z]:)/, "$1");
|
|
12
13
|
const CLAUDE_DIR = join(homedir(), ".claude");
|
|
@@ -291,18 +292,36 @@ if (codexProfilesAdded > 0) {
|
|
|
291
292
|
synced++;
|
|
292
293
|
}
|
|
293
294
|
|
|
294
|
-
// ── MCP 인벤토리 백그라운드 갱신 ──
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
if (existsSync(mcpCheck)) {
|
|
300
|
-
const child = spawn(process.execPath, [mcpCheck], {
|
|
295
|
+
// ── MCP 인벤토리 백그라운드 갱신 ──
|
|
296
|
+
|
|
297
|
+
const mcpCheck = join(PLUGIN_ROOT, "scripts", "mcp-check.mjs");
|
|
298
|
+
if (existsSync(mcpCheck)) {
|
|
299
|
+
const child = spawn(process.execPath, [mcpCheck], {
|
|
301
300
|
detached: true,
|
|
302
301
|
stdio: "ignore",
|
|
303
302
|
});
|
|
304
|
-
child.unref(); // 부모 프로세스와 분리 — 비동기 실행
|
|
305
|
-
}
|
|
303
|
+
child.unref(); // 부모 프로세스와 분리 — 비동기 실행
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Hub 헬스체크 + 자동 기동 (세션 시작 백그라운드) ──
|
|
307
|
+
// setup 훅이 포그라운드 지연을 만들지 않도록 별도 detached 프로세스로 처리한다.
|
|
308
|
+
const hubEnsure = join(PLUGIN_ROOT, "scripts", "hub-ensure.mjs");
|
|
309
|
+
const isPostinstall = process.env.npm_lifecycle_event === "postinstall";
|
|
310
|
+
const isCi = /^(1|true)$/i.test(process.env.CI || "");
|
|
311
|
+
const disableHubAutostart = process.env.TFX_DISABLE_HUB_AUTOSTART === "1";
|
|
312
|
+
|
|
313
|
+
if (!isPostinstall && !isCi && !disableHubAutostart && existsSync(hubEnsure)) {
|
|
314
|
+
try {
|
|
315
|
+
const child = spawn(process.execPath, [hubEnsure], {
|
|
316
|
+
env: process.env,
|
|
317
|
+
detached: true,
|
|
318
|
+
stdio: "ignore",
|
|
319
|
+
});
|
|
320
|
+
child.unref();
|
|
321
|
+
} catch {
|
|
322
|
+
// best effort: 실패해도 setup 흐름은 지속
|
|
323
|
+
}
|
|
324
|
+
}
|
|
306
325
|
|
|
307
326
|
// ── postinstall 배너 (npm install 시에만 출력) ──
|
|
308
327
|
|
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
|
|