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 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
- const PKG_ROOT = dirname(dirname(new URL(import.meta.url).pathname)).replace(/^\/([A-Z]:)/, "$1");
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.includes("--dev");
681
- const tagLabel = isDev ? ` ${YELLOW}@dev${RESET}` : "";
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(`npm update — ${result || "완료"}`);
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} ${GRAY}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 inject = [];
969
- if (!isControl && !hasLead) inject.push("--lead", "codex");
970
- if (!isControl && !hasAgents) inject.push("--agents", "codex,codex");
971
- if (!isControl && !hasLayout) inject.push("--layout", "1xN");
972
- const forwarded = isControl ? args : [...inject, ...args];
973
-
974
- const { pathToFileURL } = await import("node:url");
975
- const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli.mjs")).href);
976
-
977
- const prevArgv = process.argv;
978
- process.argv = [prevArgv[0], prevArgv[1], "team", ...forwarded];
979
- try {
980
- await cmdTeam();
981
- } finally {
982
- process.argv = prevArgv;
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
- switch (sub) {
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
- execSync("node -e \"setTimeout(()=>{},100)\"", { stdio: "ignore", timeout: 500 });
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
- console.log(`\n ${DIM}hub 미실행${RESET}\n`);
1127
- return;
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
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`);
1144
- return;
1145
- }
1146
- try {
1147
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
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 statusUrl = info.url.replace("/mcp", "/status");
1162
- const result = execSync(`curl -s "${statusUrl}"`, { encoding: "utf8", timeout: 3000, stdio: ["pipe", "pipe", "ignore"] });
1163
- const data = JSON.parse(result);
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
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
1176
- }
1177
- break;
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 — 허브 상태 (브라우저/curl 용)
120
- if (req.url === '/' || req.url === '/status') {
121
- res.writeHead(200, { 'Content-Type': 'application/json' });
122
- return res.end(JSON.stringify({
123
- ...router.getStatus('hub').data,
124
- sessions: transports.size,
125
- pid: process.pid,
126
- port,
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 { execSync, spawn } from "node:child_process";
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 TEAM_STATE_FILE = join(HUB_PID_DIR, "team-state.json");
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
- writeFileSync(TEAM_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
67
- }
68
-
69
- function clearTeamState() {
70
- try { unlinkSync(TEAM_STATE_FILE); } catch {}
71
- }
72
-
73
- // ── Hub 유틸 ──
74
-
75
- function getHubInfo() {
76
- if (!existsSync(HUB_PID_FILE)) return null;
77
- try {
78
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
79
- process.kill(info.pid, 0); // 프로세스 생존 확인
80
- return info;
81
- } catch {
82
- return null;
83
- }
84
- }
85
-
86
- function startHubDaemon() {
87
- const serverPath = join(PKG_ROOT, "hub", "server.mjs");
88
- if (!existsSync(serverPath)) {
89
- fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음");
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
- // PID 파일 확인 (최대 3초 대기)
101
- const deadline = Date.now() + 3000;
102
- while (Date.now() < deadline) {
103
- if (existsSync(HUB_PID_FILE)) {
104
- return JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
105
- }
106
- execSync('node -e "setTimeout(()=>{},100)"', { stdio: "ignore", timeout: 500 });
107
- }
108
- return null;
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 || "http://127.0.0.1:27888/mcp").replace(/\/mcp$/, "");
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
- return hasWindowsTerminal() && hasWindowsTerminalSession();
367
- }
368
- return sessionExists(state.sessionName);
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 || "http://127.0.0.1:27888/mcp";
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 taskListCommand = `${process.execPath} ${join(PKG_ROOT, "bin", "triflux.mjs")} team tasks`;
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}Escape: 현재 팀메이트 인터럽트${RESET}`);
763
- console.log(` ${DIM}Ctrl+T: 태스크 목록${RESET}`);
764
- console.log(` ${DIM}Ctrl+B → D: 세션 분리 (백그라운드)${RESET}\n`);
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 || "http://127.0.0.1:27888/mcp").replace(/\/mcp$/, "");
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(` mode: ${state.teammateMode || "tmux"}`);
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+Up${RESET} ${GRAY}이전 팀메이트${RESET}
1342
- ${WHITE}Escape${RESET} ${GRAY}현재 팀메이트 인터럽트${RESET}
1343
- ${WHITE}Ctrl+T${RESET} ${GRAY}태스크 목록 토글${RESET}
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 sub = process.argv[3];
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
+ }
@@ -393,33 +393,49 @@ export function focusPane(target, opts = {}) {
393
393
  }
394
394
  }
395
395
 
396
- /**
397
- * 팀메이트 조작 키 바인딩 설정
398
- * - Shift+Down: 다음 팀메이트
399
- * - Shift+Up: 이전 팀메이트
400
- * - Escape: 현재 팀메이트 인터럽트(C-c)
401
- * - Ctrl+T: 태스크 목록 표시
402
- * @param {string} sessionName
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
- if (inProcess) {
412
- // 단일 뷰(zoom) 상태에서 팀메이트 순환
413
- tmux(`bind-key -T root -n S-Down if-shell -F '${cond}' 'select-pane -t :.+ \\; resize-pane -Z' 'send-keys S-Down'`);
414
- tmux(`bind-key -T root -n S-Up if-shell -F '${cond}' 'select-pane -t :.- \\; resize-pane -Z' 'send-keys S-Up'`);
415
- } else {
416
- // 분할 뷰에서 팀메이트 순환
417
- tmux(`bind-key -T root -n S-Down if-shell -F '${cond}' 'select-pane -t :.+' 'send-keys S-Down'`);
418
- tmux(`bind-key -T root -n S-Up if-shell -F '${cond}' 'select-pane -t :.-' 'send-keys S-Up'`);
419
- }
420
-
421
- // 현재 활성 pane 인터럽트
422
- tmux(`bind-key -T root -n Escape if-shell -F '${cond}' 'send-keys C-c' 'send-keys Escape'`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "3.2.0-dev.5",
3
+ "version": "3.2.0-dev.7",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- const PLUGIN_ROOT = dirname(dirname(new URL(import.meta.url).pathname)).replace(/^\/([A-Z]:)/, "$1");
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
- const srcVersion = getVersion(src);
118
- const dstVersion = getVersion(dst);
119
- if (srcVersion && dstVersion && srcVersion !== dstVersion) {
120
- copyFileSync(src, dst);
121
- try { chmodSync(dst, 0o755); } catch {}
122
- synced++;
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
- import { spawn } from "child_process";
297
-
298
- const mcpCheck = join(PLUGIN_ROOT, "scripts", "mcp-check.mjs");
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 축약
@@ -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