triflux 7.3.2 → 7.5.0
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/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +145 -145
- package/README.md +145 -145
- package/hub/pipeline/index.mjs +318 -318
- package/hub/schema.sql +146 -146
- package/hub/team/agent-map.json +2 -1
- package/hub/team/backend.mjs +3 -3
- package/hub/team/cli/commands/kill.mjs +37 -37
- package/hub/team/cli/commands/start/parse-args.mjs +4 -2
- package/hub/team/cli/commands/stop.mjs +31 -31
- package/hub/team/cli/commands/task.mjs +30 -30
- package/hub/team/cli/services/hub-client.mjs +208 -208
- package/hub/team/cli/services/native-control.mjs +4 -1
- package/hub/team/cli/services/runtime-mode.mjs +62 -62
- package/hub/team/cli/services/state-store.mjs +48 -48
- package/hub/team/codex-compat.mjs +78 -0
- package/hub/team/dashboard.mjs +274 -274
- package/hub/team/native.mjs +649 -649
- package/hub/team/pane.mjs +154 -150
- package/hub/team/psmux.mjs +1041 -1023
- package/hub/team/tui-viewer.mjs +2 -2
- package/hub/team/tui.mjs +12 -1
- package/hub/tools.mjs +554 -554
- package/hud/constants.mjs +3 -0
- package/package.json +1 -1
- package/scripts/claude-logged.ps1 +54 -0
- package/scripts/headless-guard.mjs +94 -7
- package/scripts/lib/mcp-filter.mjs +720 -720
- package/scripts/preflight-cache.mjs +137 -137
- package/scripts/remote-spawn.mjs +222 -0
- package/scripts/setup.mjs +84 -1
- package/scripts/tfx-gate-activate.mjs +89 -0
- package/scripts/tfx-route-post.mjs +17 -13
- package/scripts/tfx-route.sh +118 -46
- package/scripts/token-snapshot.mjs +575 -575
- package/skills/remote-spawn/SKILL.md +63 -0
- package/skills/tfx-auto/SKILL.md +1 -1
- package/skills/tfx-multi/SKILL.md +1 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// hub/team/codex-compat.mjs — Codex CLI 버전 어댑터
|
|
2
|
+
// Codex 0.117.0+ (Rust 리라이트): exec 서브커맨드 기반
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
let _cachedVersion = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `codex --version` 실행 결과를 파싱하여 마이너 버전 숫자 반환.
|
|
9
|
+
* 파싱 실패 시 0 반환 (구버전으로 간주).
|
|
10
|
+
* @returns {number} 마이너 버전 (예: 0.117.0 → 117)
|
|
11
|
+
*/
|
|
12
|
+
export function getCodexVersion() {
|
|
13
|
+
if (_cachedVersion !== null) return _cachedVersion;
|
|
14
|
+
try {
|
|
15
|
+
const out = execSync("codex --version", { encoding: "utf8", timeout: 5000 }).trim();
|
|
16
|
+
// "codex 0.117.0" 또는 "0.117.0" 형식 대응
|
|
17
|
+
const m = out.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
18
|
+
_cachedVersion = m ? parseInt(m[2], 10) : 0;
|
|
19
|
+
} catch {
|
|
20
|
+
_cachedVersion = 0;
|
|
21
|
+
}
|
|
22
|
+
return _cachedVersion;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 최소 마이너 버전 이상인지 확인.
|
|
27
|
+
* @param {number} minMinor
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function gte(minMinor) {
|
|
31
|
+
return getCodexVersion() >= minMinor;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Codex CLI 기능별 분기 객체.
|
|
36
|
+
* 117 = 0.117.0 (Rust 리라이트, exec 서브커맨드 도입)
|
|
37
|
+
*/
|
|
38
|
+
export const FEATURES = {
|
|
39
|
+
/** exec 서브커맨드 사용 가능 여부 */
|
|
40
|
+
get execSubcommand() { return gte(117); },
|
|
41
|
+
/** --output-last-message 플래그 지원 여부 */
|
|
42
|
+
get outputLastMessage() { return gte(117); },
|
|
43
|
+
/** --color never 플래그 지원 여부 */
|
|
44
|
+
get colorNever() { return gte(117); },
|
|
45
|
+
/** 플러그인 시스템 지원 여부 (향후 확장용) */
|
|
46
|
+
get pluginSystem() { return gte(120); },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* long-form 플래그 기반 명령 빌더.
|
|
51
|
+
* @param {string} prompt
|
|
52
|
+
* @param {string|null} resultFile — null이면 --output-last-message 생략
|
|
53
|
+
* @param {{ profile?: string, skipGitRepoCheck?: boolean, sandboxBypass?: boolean }} [opts]
|
|
54
|
+
* @returns {string} 실행할 셸 커맨드
|
|
55
|
+
*/
|
|
56
|
+
export function buildExecCommand(prompt, resultFile = null, opts = {}) {
|
|
57
|
+
const { profile, skipGitRepoCheck = true, sandboxBypass = true } = opts;
|
|
58
|
+
|
|
59
|
+
const parts = ["codex"];
|
|
60
|
+
if (profile) parts.push("--profile", profile);
|
|
61
|
+
|
|
62
|
+
if (FEATURES.execSubcommand) {
|
|
63
|
+
parts.push("exec");
|
|
64
|
+
if (sandboxBypass) parts.push("--dangerously-bypass-approvals-and-sandbox");
|
|
65
|
+
if (skipGitRepoCheck) parts.push("--skip-git-repo-check");
|
|
66
|
+
if (resultFile && FEATURES.outputLastMessage) {
|
|
67
|
+
parts.push("--output-last-message", resultFile);
|
|
68
|
+
}
|
|
69
|
+
if (FEATURES.colorNever) parts.push("--color", "never");
|
|
70
|
+
} else {
|
|
71
|
+
// 구버전 fallback
|
|
72
|
+
parts.push("--dangerously-bypass-approvals-and-sandbox");
|
|
73
|
+
if (skipGitRepoCheck) parts.push("--skip-git-repo-check");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
parts.push(JSON.stringify(prompt));
|
|
77
|
+
return parts.join(" ");
|
|
78
|
+
}
|
package/hub/team/dashboard.mjs
CHANGED
|
@@ -1,274 +1,274 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// hub/team/dashboard.mjs — 실시간 팀 상태 표시 (v2.2)
|
|
3
|
-
// tmux 의존 제거 — Hub task-list + native-supervisor 기반
|
|
4
|
-
//
|
|
5
|
-
// 실행:
|
|
6
|
-
// node hub/team/dashboard.mjs --session <세션이름> [--interval 2]
|
|
7
|
-
// node hub/team/dashboard.mjs --team <팀이름> [--interval 2]
|
|
8
|
-
import { get } from "node:http";
|
|
9
|
-
import { AMBER, GREEN, RED, GRAY, DIM, BOLD, RESET } from "./shared.mjs";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* HTTP GET JSON
|
|
13
|
-
* @param {string} url
|
|
14
|
-
* @returns {Promise<object|null>}
|
|
15
|
-
*/
|
|
16
|
-
function fetchJson(url) {
|
|
17
|
-
return new Promise((resolve) => {
|
|
18
|
-
const req = get(url, { timeout: 2000 }, (res) => {
|
|
19
|
-
let data = "";
|
|
20
|
-
res.on("data", (chunk) => (data += chunk));
|
|
21
|
-
res.on("end", () => {
|
|
22
|
-
try { resolve(JSON.parse(data)); } catch { resolve(null); }
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
req.on("error", () => resolve(null));
|
|
26
|
-
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* HTTP POST JSON (Hub bridge 용)
|
|
32
|
-
* @param {string} url
|
|
33
|
-
* @param {object} body
|
|
34
|
-
* @returns {Promise<object|null>}
|
|
35
|
-
*/
|
|
36
|
-
async function fetchPost(url, body = {}) {
|
|
37
|
-
try {
|
|
38
|
-
const res = await fetch(url, {
|
|
39
|
-
method: "POST",
|
|
40
|
-
headers: { "Content-Type": "application/json" },
|
|
41
|
-
body: JSON.stringify(body),
|
|
42
|
-
signal: AbortSignal.timeout(2000),
|
|
43
|
-
});
|
|
44
|
-
return await res.json();
|
|
45
|
-
} catch {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* 진행률 바 생성
|
|
52
|
-
* @param {number} pct — 0~100
|
|
53
|
-
* @param {number} width — 바 너비 (기본 8)
|
|
54
|
-
* @returns {string}
|
|
55
|
-
*/
|
|
56
|
-
function progressBar(pct, width = 8) {
|
|
57
|
-
const filled = Math.round((pct / 100) * width);
|
|
58
|
-
const empty = width - filled;
|
|
59
|
-
return `${GREEN}${"█".repeat(filled)}${GRAY}${"░".repeat(empty)}${RESET}`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* 업타임 포맷
|
|
64
|
-
* @param {number} ms
|
|
65
|
-
* @returns {string}
|
|
66
|
-
*/
|
|
67
|
-
function formatUptime(ms) {
|
|
68
|
-
if (ms < 60000) return `${Math.round(ms / 1000)}초`;
|
|
69
|
-
if (ms < 3600000) return `${Math.round(ms / 60000)}분`;
|
|
70
|
-
return `${Math.round(ms / 3600000)}시간`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* task 상태 아이콘
|
|
75
|
-
* @param {string} status
|
|
76
|
-
* @returns {string}
|
|
77
|
-
*/
|
|
78
|
-
function statusIcon(status) {
|
|
79
|
-
switch (status) {
|
|
80
|
-
case "completed": return `${GREEN}✓${RESET}`;
|
|
81
|
-
case "in_progress": return `${AMBER}●${RESET}`;
|
|
82
|
-
case "failed": return `${RED}✗${RESET}`;
|
|
83
|
-
default: return `${GRAY}○${RESET}`;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* 멤버 목록 구성: Hub tasks + supervisor + teamState 통합
|
|
89
|
-
* @param {Array} hubTasks — Hub bridge task-list 결과
|
|
90
|
-
* @param {Array} supervisorMembers — native-supervisor 멤버 상태
|
|
91
|
-
* @param {object} teamState — team-state.json 내용
|
|
92
|
-
* @returns {Array<{name: string, cli: string, status: string, subject: string, preview: string}>}
|
|
93
|
-
*/
|
|
94
|
-
function buildMemberList(hubTasks, supervisorMembers, teamState) {
|
|
95
|
-
const members = [];
|
|
96
|
-
const supervisorByName = new Map(supervisorMembers.map((m) => [m.name, m]));
|
|
97
|
-
|
|
98
|
-
// Hub tasks가 있으면 주 데이터 소스
|
|
99
|
-
if (hubTasks.length > 0) {
|
|
100
|
-
for (const task of hubTasks) {
|
|
101
|
-
const owner = task.owner || task.subject || "";
|
|
102
|
-
const sup = supervisorByName.get(owner);
|
|
103
|
-
members.push({
|
|
104
|
-
name: owner,
|
|
105
|
-
cli: task.metadata?.cli || sup?.cli || "",
|
|
106
|
-
status: task.status || "pending",
|
|
107
|
-
subject: task.subject || "",
|
|
108
|
-
preview: sup?.lastPreview || task.description?.slice(0, 80) || "",
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
return members;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Supervisor 데이터 폴백
|
|
115
|
-
if (supervisorMembers.length > 0) {
|
|
116
|
-
for (const m of supervisorMembers) {
|
|
117
|
-
if (m.role === "lead") continue;
|
|
118
|
-
members.push({
|
|
119
|
-
name: m.name,
|
|
120
|
-
cli: m.cli || "",
|
|
121
|
-
status: m.status === "running" ? "in_progress" : m.status === "exited" ? "completed" : m.status,
|
|
122
|
-
subject: "",
|
|
123
|
-
preview: m.lastPreview || "",
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
return members;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// teamState 폴백 (하위 호환)
|
|
130
|
-
const panes = teamState?.panes || {};
|
|
131
|
-
for (const [, paneInfo] of Object.entries(panes).filter(([, v]) => v.role !== "dashboard" && v.role !== "lead")) {
|
|
132
|
-
members.push({
|
|
133
|
-
name: paneInfo.agentId || paneInfo.name || "?",
|
|
134
|
-
cli: paneInfo.cli || "",
|
|
135
|
-
status: "unknown",
|
|
136
|
-
subject: paneInfo.subtask || "",
|
|
137
|
-
preview: "",
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
return members;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* 대시보드 렌더링 (v2.2: Hub/supervisor 기반)
|
|
145
|
-
* @param {string} sessionName — 세션 또는 팀 이름
|
|
146
|
-
* @param {object} opts
|
|
147
|
-
* @param {string} opts.hubUrl — Hub URL (기본 http://127.0.0.1:27888)
|
|
148
|
-
* @param {string} [opts.teamName] — Hub task-list 조회용 팀 이름
|
|
149
|
-
* @param {string} [opts.supervisorUrl] — native-supervisor 제어 URL
|
|
150
|
-
* @param {object} [opts.teamState] — team-state.json 내용 (하위 호환)
|
|
151
|
-
*/
|
|
152
|
-
export async function renderDashboard(sessionName, opts = {}) {
|
|
153
|
-
const {
|
|
154
|
-
hubUrl = "http://127.0.0.1:27888",
|
|
155
|
-
teamName,
|
|
156
|
-
supervisorUrl,
|
|
157
|
-
teamState = {},
|
|
158
|
-
} = opts;
|
|
159
|
-
const W = 50;
|
|
160
|
-
const border = "─".repeat(W);
|
|
161
|
-
|
|
162
|
-
// 데이터 수집 (병렬)
|
|
163
|
-
const [hubStatus, taskListRes, supervisorRes] = await Promise.all([
|
|
164
|
-
fetchJson(`${hubUrl}/status`),
|
|
165
|
-
teamName ? fetchPost(`${hubUrl}/bridge/team/task-list`, { team_name: teamName }) : null,
|
|
166
|
-
supervisorUrl ? fetchJson(`${supervisorUrl}/status`) : null,
|
|
167
|
-
]);
|
|
168
|
-
|
|
169
|
-
const hubOnline = !!hubStatus;
|
|
170
|
-
const hubState = hubOnline ? `${GREEN}● online${RESET}` : `${RED}● offline${RESET}`;
|
|
171
|
-
const uptime = hubStatus?.hub?.uptime ? formatUptime(hubStatus.hub.uptime) : "-";
|
|
172
|
-
const queueSize = hubStatus?.hub?.queue_depth ?? 0;
|
|
173
|
-
|
|
174
|
-
// Hub task 데이터
|
|
175
|
-
const hubTasks = taskListRes?.ok ? (taskListRes.data?.tasks || []) : [];
|
|
176
|
-
const completedCount = hubTasks.filter((t) => t.status === "completed").length;
|
|
177
|
-
const totalCount = hubTasks.length;
|
|
178
|
-
|
|
179
|
-
// Supervisor 멤버 데이터
|
|
180
|
-
const supervisorMembers = supervisorRes?.ok ? (supervisorRes.data?.members || []) : [];
|
|
181
|
-
|
|
182
|
-
// 헤더
|
|
183
|
-
const progress = totalCount > 0 ? ` ${completedCount}/${totalCount}` : "";
|
|
184
|
-
console.log(`${AMBER}┌─ ${sessionName}${progress} ${GRAY}${"─".repeat(Math.max(0, W - sessionName.length - progress.length - 3))}${AMBER}┐${RESET}`);
|
|
185
|
-
console.log(`${AMBER}│${RESET} Hub: ${hubState} Uptime: ${DIM}${uptime}${RESET} Queue: ${DIM}${queueSize}${RESET}`);
|
|
186
|
-
console.log(`${AMBER}│${RESET}`);
|
|
187
|
-
|
|
188
|
-
// 멤버/워커 렌더링
|
|
189
|
-
const members = buildMemberList(hubTasks, supervisorMembers, teamState);
|
|
190
|
-
|
|
191
|
-
if (members.length === 0) {
|
|
192
|
-
console.log(`${AMBER}│${RESET} ${DIM}에이전트 정보 없음${RESET}`);
|
|
193
|
-
} else {
|
|
194
|
-
for (const m of members) {
|
|
195
|
-
const icon = statusIcon(m.status);
|
|
196
|
-
const label = `[${m.name}]`;
|
|
197
|
-
const cliTag = m.cli ? m.cli.charAt(0).toUpperCase() + m.cli.slice(1) : "";
|
|
198
|
-
|
|
199
|
-
// 진행률 추정
|
|
200
|
-
const pct = m.status === "completed" ? 100
|
|
201
|
-
: m.status === "in_progress" ? 50
|
|
202
|
-
: m.status === "failed" ? 100
|
|
203
|
-
: 0;
|
|
204
|
-
|
|
205
|
-
console.log(`${AMBER}│${RESET} ${BOLD}${label}${RESET} ${cliTag} ${icon} ${m.status || "pending"} ${progressBar(pct)}`);
|
|
206
|
-
|
|
207
|
-
// 미리보기: supervisor lastPreview > task subject
|
|
208
|
-
const preview = m.preview || m.subject || "";
|
|
209
|
-
if (preview) {
|
|
210
|
-
const truncated = preview.length > W - 8 ? preview.slice(0, W - 11) + "..." : preview;
|
|
211
|
-
console.log(`${AMBER}│${RESET} ${DIM}> ${truncated}${RESET}`);
|
|
212
|
-
}
|
|
213
|
-
console.log(`${AMBER}│${RESET}`);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// 푸터
|
|
218
|
-
console.log(`${AMBER}└${GRAY}${border}${AMBER}┘${RESET}`);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** team-state.json 로드 (세션별 파일 우선, fallback: team-state.json) */
|
|
222
|
-
async function loadTeamState() {
|
|
223
|
-
try {
|
|
224
|
-
const { existsSync, readFileSync } = await import("node:fs");
|
|
225
|
-
const { join } = await import("node:path");
|
|
226
|
-
const { homedir } = await import("node:os");
|
|
227
|
-
const hubDir = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
228
|
-
const sessionId = process.env.CLAUDE_SESSION_ID;
|
|
229
|
-
if (sessionId) {
|
|
230
|
-
const sessionPath = join(hubDir, `team-state-${sessionId}.json`);
|
|
231
|
-
if (existsSync(sessionPath)) return JSON.parse(readFileSync(sessionPath, "utf8"));
|
|
232
|
-
}
|
|
233
|
-
const legacyPath = join(hubDir, "team-state.json");
|
|
234
|
-
if (existsSync(legacyPath)) return JSON.parse(readFileSync(legacyPath, "utf8"));
|
|
235
|
-
return {};
|
|
236
|
-
} catch {
|
|
237
|
-
return {};
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// ── CLI 실행 ──
|
|
242
|
-
if (process.argv[1]?.includes("dashboard.mjs")) {
|
|
243
|
-
const sessionIdx = process.argv.indexOf("--session");
|
|
244
|
-
const teamIdx = process.argv.indexOf("--team");
|
|
245
|
-
const sessionName = sessionIdx !== -1 ? process.argv[sessionIdx + 1] : null;
|
|
246
|
-
const teamName = teamIdx !== -1 ? process.argv[teamIdx + 1] : null;
|
|
247
|
-
const intervalSec = parseInt(process.argv[process.argv.indexOf("--interval") + 1] || "2", 10);
|
|
248
|
-
|
|
249
|
-
const displayName = sessionName || teamName;
|
|
250
|
-
if (!displayName) {
|
|
251
|
-
console.error("사용법: node dashboard.mjs --session <세션이름> [--team <팀이름>] [--interval 2]");
|
|
252
|
-
process.exit(1);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Ctrl+C로 종료
|
|
256
|
-
process.on("SIGINT", () => process.exit(0));
|
|
257
|
-
|
|
258
|
-
// 갱신 루프
|
|
259
|
-
while (true) {
|
|
260
|
-
const teamState = await loadTeamState();
|
|
261
|
-
const effectiveTeamName = teamName || null;
|
|
262
|
-
const supervisorUrl = teamState?.native?.controlUrl || null;
|
|
263
|
-
|
|
264
|
-
// 화면 클리어 (ANSI)
|
|
265
|
-
process.stdout.write("\x1b[2J\x1b[H");
|
|
266
|
-
await renderDashboard(displayName, {
|
|
267
|
-
teamName: effectiveTeamName,
|
|
268
|
-
supervisorUrl,
|
|
269
|
-
teamState,
|
|
270
|
-
});
|
|
271
|
-
console.log(`${DIM} ${intervalSec}초 간격 갱신 | Ctrl+C로 종료${RESET}`);
|
|
272
|
-
await new Promise((r) => setTimeout(r, intervalSec * 1000));
|
|
273
|
-
}
|
|
274
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hub/team/dashboard.mjs — 실시간 팀 상태 표시 (v2.2)
|
|
3
|
+
// tmux 의존 제거 — Hub task-list + native-supervisor 기반
|
|
4
|
+
//
|
|
5
|
+
// 실행:
|
|
6
|
+
// node hub/team/dashboard.mjs --session <세션이름> [--interval 2]
|
|
7
|
+
// node hub/team/dashboard.mjs --team <팀이름> [--interval 2]
|
|
8
|
+
import { get } from "node:http";
|
|
9
|
+
import { AMBER, GREEN, RED, GRAY, DIM, BOLD, RESET } from "./shared.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* HTTP GET JSON
|
|
13
|
+
* @param {string} url
|
|
14
|
+
* @returns {Promise<object|null>}
|
|
15
|
+
*/
|
|
16
|
+
function fetchJson(url) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const req = get(url, { timeout: 2000 }, (res) => {
|
|
19
|
+
let data = "";
|
|
20
|
+
res.on("data", (chunk) => (data += chunk));
|
|
21
|
+
res.on("end", () => {
|
|
22
|
+
try { resolve(JSON.parse(data)); } catch { resolve(null); }
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
req.on("error", () => resolve(null));
|
|
26
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* HTTP POST JSON (Hub bridge 용)
|
|
32
|
+
* @param {string} url
|
|
33
|
+
* @param {object} body
|
|
34
|
+
* @returns {Promise<object|null>}
|
|
35
|
+
*/
|
|
36
|
+
async function fetchPost(url, body = {}) {
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(url, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
signal: AbortSignal.timeout(2000),
|
|
43
|
+
});
|
|
44
|
+
return await res.json();
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 진행률 바 생성
|
|
52
|
+
* @param {number} pct — 0~100
|
|
53
|
+
* @param {number} width — 바 너비 (기본 8)
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function progressBar(pct, width = 8) {
|
|
57
|
+
const filled = Math.round((pct / 100) * width);
|
|
58
|
+
const empty = width - filled;
|
|
59
|
+
return `${GREEN}${"█".repeat(filled)}${GRAY}${"░".repeat(empty)}${RESET}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 업타임 포맷
|
|
64
|
+
* @param {number} ms
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function formatUptime(ms) {
|
|
68
|
+
if (ms < 60000) return `${Math.round(ms / 1000)}초`;
|
|
69
|
+
if (ms < 3600000) return `${Math.round(ms / 60000)}분`;
|
|
70
|
+
return `${Math.round(ms / 3600000)}시간`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* task 상태 아이콘
|
|
75
|
+
* @param {string} status
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function statusIcon(status) {
|
|
79
|
+
switch (status) {
|
|
80
|
+
case "completed": return `${GREEN}✓${RESET}`;
|
|
81
|
+
case "in_progress": return `${AMBER}●${RESET}`;
|
|
82
|
+
case "failed": return `${RED}✗${RESET}`;
|
|
83
|
+
default: return `${GRAY}○${RESET}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 멤버 목록 구성: Hub tasks + supervisor + teamState 통합
|
|
89
|
+
* @param {Array} hubTasks — Hub bridge task-list 결과
|
|
90
|
+
* @param {Array} supervisorMembers — native-supervisor 멤버 상태
|
|
91
|
+
* @param {object} teamState — team-state.json 내용
|
|
92
|
+
* @returns {Array<{name: string, cli: string, status: string, subject: string, preview: string}>}
|
|
93
|
+
*/
|
|
94
|
+
function buildMemberList(hubTasks, supervisorMembers, teamState) {
|
|
95
|
+
const members = [];
|
|
96
|
+
const supervisorByName = new Map(supervisorMembers.map((m) => [m.name, m]));
|
|
97
|
+
|
|
98
|
+
// Hub tasks가 있으면 주 데이터 소스
|
|
99
|
+
if (hubTasks.length > 0) {
|
|
100
|
+
for (const task of hubTasks) {
|
|
101
|
+
const owner = task.owner || task.subject || "";
|
|
102
|
+
const sup = supervisorByName.get(owner);
|
|
103
|
+
members.push({
|
|
104
|
+
name: owner,
|
|
105
|
+
cli: task.metadata?.cli || sup?.cli || "",
|
|
106
|
+
status: task.status || "pending",
|
|
107
|
+
subject: task.subject || "",
|
|
108
|
+
preview: sup?.lastPreview || task.description?.slice(0, 80) || "",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return members;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Supervisor 데이터 폴백
|
|
115
|
+
if (supervisorMembers.length > 0) {
|
|
116
|
+
for (const m of supervisorMembers) {
|
|
117
|
+
if (m.role === "lead") continue;
|
|
118
|
+
members.push({
|
|
119
|
+
name: m.name,
|
|
120
|
+
cli: m.cli || "",
|
|
121
|
+
status: m.status === "running" ? "in_progress" : m.status === "exited" ? "completed" : m.status,
|
|
122
|
+
subject: "",
|
|
123
|
+
preview: m.lastPreview || "",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return members;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// teamState 폴백 (하위 호환)
|
|
130
|
+
const panes = teamState?.panes || {};
|
|
131
|
+
for (const [, paneInfo] of Object.entries(panes).filter(([, v]) => v.role !== "dashboard" && v.role !== "lead")) {
|
|
132
|
+
members.push({
|
|
133
|
+
name: paneInfo.agentId || paneInfo.name || "?",
|
|
134
|
+
cli: paneInfo.cli || "",
|
|
135
|
+
status: "unknown",
|
|
136
|
+
subject: paneInfo.subtask || "",
|
|
137
|
+
preview: "",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return members;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 대시보드 렌더링 (v2.2: Hub/supervisor 기반)
|
|
145
|
+
* @param {string} sessionName — 세션 또는 팀 이름
|
|
146
|
+
* @param {object} opts
|
|
147
|
+
* @param {string} opts.hubUrl — Hub URL (기본 http://127.0.0.1:27888)
|
|
148
|
+
* @param {string} [opts.teamName] — Hub task-list 조회용 팀 이름
|
|
149
|
+
* @param {string} [opts.supervisorUrl] — native-supervisor 제어 URL
|
|
150
|
+
* @param {object} [opts.teamState] — team-state.json 내용 (하위 호환)
|
|
151
|
+
*/
|
|
152
|
+
export async function renderDashboard(sessionName, opts = {}) {
|
|
153
|
+
const {
|
|
154
|
+
hubUrl = "http://127.0.0.1:27888",
|
|
155
|
+
teamName,
|
|
156
|
+
supervisorUrl,
|
|
157
|
+
teamState = {},
|
|
158
|
+
} = opts;
|
|
159
|
+
const W = 50;
|
|
160
|
+
const border = "─".repeat(W);
|
|
161
|
+
|
|
162
|
+
// 데이터 수집 (병렬)
|
|
163
|
+
const [hubStatus, taskListRes, supervisorRes] = await Promise.all([
|
|
164
|
+
fetchJson(`${hubUrl}/status`),
|
|
165
|
+
teamName ? fetchPost(`${hubUrl}/bridge/team/task-list`, { team_name: teamName }) : null,
|
|
166
|
+
supervisorUrl ? fetchJson(`${supervisorUrl}/status`) : null,
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
const hubOnline = !!hubStatus;
|
|
170
|
+
const hubState = hubOnline ? `${GREEN}● online${RESET}` : `${RED}● offline${RESET}`;
|
|
171
|
+
const uptime = hubStatus?.hub?.uptime ? formatUptime(hubStatus.hub.uptime) : "-";
|
|
172
|
+
const queueSize = hubStatus?.hub?.queue_depth ?? 0;
|
|
173
|
+
|
|
174
|
+
// Hub task 데이터
|
|
175
|
+
const hubTasks = taskListRes?.ok ? (taskListRes.data?.tasks || []) : [];
|
|
176
|
+
const completedCount = hubTasks.filter((t) => t.status === "completed").length;
|
|
177
|
+
const totalCount = hubTasks.length;
|
|
178
|
+
|
|
179
|
+
// Supervisor 멤버 데이터
|
|
180
|
+
const supervisorMembers = supervisorRes?.ok ? (supervisorRes.data?.members || []) : [];
|
|
181
|
+
|
|
182
|
+
// 헤더
|
|
183
|
+
const progress = totalCount > 0 ? ` ${completedCount}/${totalCount}` : "";
|
|
184
|
+
console.log(`${AMBER}┌─ ${sessionName}${progress} ${GRAY}${"─".repeat(Math.max(0, W - sessionName.length - progress.length - 3))}${AMBER}┐${RESET}`);
|
|
185
|
+
console.log(`${AMBER}│${RESET} Hub: ${hubState} Uptime: ${DIM}${uptime}${RESET} Queue: ${DIM}${queueSize}${RESET}`);
|
|
186
|
+
console.log(`${AMBER}│${RESET}`);
|
|
187
|
+
|
|
188
|
+
// 멤버/워커 렌더링
|
|
189
|
+
const members = buildMemberList(hubTasks, supervisorMembers, teamState);
|
|
190
|
+
|
|
191
|
+
if (members.length === 0) {
|
|
192
|
+
console.log(`${AMBER}│${RESET} ${DIM}에이전트 정보 없음${RESET}`);
|
|
193
|
+
} else {
|
|
194
|
+
for (const m of members) {
|
|
195
|
+
const icon = statusIcon(m.status);
|
|
196
|
+
const label = `[${m.name}]`;
|
|
197
|
+
const cliTag = m.cli ? m.cli.charAt(0).toUpperCase() + m.cli.slice(1) : "";
|
|
198
|
+
|
|
199
|
+
// 진행률 추정
|
|
200
|
+
const pct = m.status === "completed" ? 100
|
|
201
|
+
: m.status === "in_progress" ? 50
|
|
202
|
+
: m.status === "failed" ? 100
|
|
203
|
+
: 0;
|
|
204
|
+
|
|
205
|
+
console.log(`${AMBER}│${RESET} ${BOLD}${label}${RESET} ${cliTag} ${icon} ${m.status || "pending"} ${progressBar(pct)}`);
|
|
206
|
+
|
|
207
|
+
// 미리보기: supervisor lastPreview > task subject
|
|
208
|
+
const preview = m.preview || m.subject || "";
|
|
209
|
+
if (preview) {
|
|
210
|
+
const truncated = preview.length > W - 8 ? preview.slice(0, W - 11) + "..." : preview;
|
|
211
|
+
console.log(`${AMBER}│${RESET} ${DIM}> ${truncated}${RESET}`);
|
|
212
|
+
}
|
|
213
|
+
console.log(`${AMBER}│${RESET}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 푸터
|
|
218
|
+
console.log(`${AMBER}└${GRAY}${border}${AMBER}┘${RESET}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** team-state.json 로드 (세션별 파일 우선, fallback: team-state.json) */
|
|
222
|
+
async function loadTeamState() {
|
|
223
|
+
try {
|
|
224
|
+
const { existsSync, readFileSync } = await import("node:fs");
|
|
225
|
+
const { join } = await import("node:path");
|
|
226
|
+
const { homedir } = await import("node:os");
|
|
227
|
+
const hubDir = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
228
|
+
const sessionId = process.env.CLAUDE_SESSION_ID;
|
|
229
|
+
if (sessionId) {
|
|
230
|
+
const sessionPath = join(hubDir, `team-state-${sessionId}.json`);
|
|
231
|
+
if (existsSync(sessionPath)) return JSON.parse(readFileSync(sessionPath, "utf8"));
|
|
232
|
+
}
|
|
233
|
+
const legacyPath = join(hubDir, "team-state.json");
|
|
234
|
+
if (existsSync(legacyPath)) return JSON.parse(readFileSync(legacyPath, "utf8"));
|
|
235
|
+
return {};
|
|
236
|
+
} catch {
|
|
237
|
+
return {};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── CLI 실행 ──
|
|
242
|
+
if (process.argv[1]?.includes("dashboard.mjs")) {
|
|
243
|
+
const sessionIdx = process.argv.indexOf("--session");
|
|
244
|
+
const teamIdx = process.argv.indexOf("--team");
|
|
245
|
+
const sessionName = sessionIdx !== -1 ? process.argv[sessionIdx + 1] : null;
|
|
246
|
+
const teamName = teamIdx !== -1 ? process.argv[teamIdx + 1] : null;
|
|
247
|
+
const intervalSec = parseInt(process.argv[process.argv.indexOf("--interval") + 1] || "2", 10);
|
|
248
|
+
|
|
249
|
+
const displayName = sessionName || teamName;
|
|
250
|
+
if (!displayName) {
|
|
251
|
+
console.error("사용법: node dashboard.mjs --session <세션이름> [--team <팀이름>] [--interval 2]");
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Ctrl+C로 종료
|
|
256
|
+
process.on("SIGINT", () => process.exit(0));
|
|
257
|
+
|
|
258
|
+
// 갱신 루프
|
|
259
|
+
while (true) {
|
|
260
|
+
const teamState = await loadTeamState();
|
|
261
|
+
const effectiveTeamName = teamName || null;
|
|
262
|
+
const supervisorUrl = teamState?.native?.controlUrl || null;
|
|
263
|
+
|
|
264
|
+
// 화면 클리어 (ANSI)
|
|
265
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
266
|
+
await renderDashboard(displayName, {
|
|
267
|
+
teamName: effectiveTeamName,
|
|
268
|
+
supervisorUrl,
|
|
269
|
+
teamState,
|
|
270
|
+
});
|
|
271
|
+
console.log(`${DIM} ${intervalSec}초 간격 갱신 | Ctrl+C로 종료${RESET}`);
|
|
272
|
+
await new Promise((r) => setTimeout(r, intervalSec * 1000));
|
|
273
|
+
}
|
|
274
|
+
}
|