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 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 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
- }
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
- switch (sub) {
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
- execSync("node -e \"setTimeout(()=>{},100)\"", { stdio: "ignore", timeout: 500 });
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
- console.log(`\n ${DIM}hub 미실행${RESET}\n`);
1127
- return;
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
- 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"));
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 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
- }
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
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
1176
- }
1177
- break;
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 — 허브 상태 (브라우저/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.6",
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,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
- 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], {
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
 
@@ -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