triflux 3.2.0-dev.1 → 3.2.0-dev.10
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 +26 -18
- package/README.md +26 -18
- package/bin/triflux.mjs +1614 -1084
- package/hooks/hooks.json +12 -0
- package/hooks/keyword-rules.json +354 -0
- package/hub/bridge.mjs +371 -193
- package/hub/hitl.mjs +45 -31
- package/hub/pipe.mjs +457 -0
- package/hub/router.mjs +422 -161
- package/hub/server.mjs +429 -344
- package/hub/store.mjs +388 -314
- package/hub/team/cli-team-common.mjs +348 -0
- package/hub/team/cli-team-control.mjs +393 -0
- package/hub/team/cli-team-start.mjs +516 -0
- package/hub/team/cli-team-status.mjs +269 -0
- package/hub/team/cli.mjs +99 -368
- package/hub/team/dashboard.mjs +165 -64
- package/hub/team/native-supervisor.mjs +300 -0
- package/hub/team/native.mjs +62 -0
- package/hub/team/nativeProxy.mjs +534 -0
- package/hub/team/orchestrator.mjs +99 -35
- package/hub/team/pane.mjs +138 -101
- package/hub/team/psmux.mjs +297 -0
- package/hub/team/session.mjs +608 -186
- package/hub/team/shared.mjs +13 -0
- package/hub/team/staleState.mjs +299 -0
- package/hub/tools.mjs +140 -53
- package/hub/workers/claude-worker.mjs +446 -0
- package/hub/workers/codex-mcp.mjs +414 -0
- package/hub/workers/factory.mjs +18 -0
- package/hub/workers/gemini-worker.mjs +349 -0
- package/hub/workers/interface.mjs +41 -0
- package/hud/hud-qos-status.mjs +1789 -1732
- package/package.json +6 -2
- package/scripts/__tests__/keyword-detector.test.mjs +234 -0
- package/scripts/hub-ensure.mjs +83 -0
- package/scripts/keyword-detector.mjs +272 -0
- package/scripts/keyword-rules-expander.mjs +521 -0
- package/scripts/lib/keyword-rules.mjs +168 -0
- package/scripts/psmux-steering-prototype.sh +368 -0
- package/scripts/run.cjs +62 -0
- package/scripts/setup.mjs +189 -7
- package/scripts/test-tfx-route-no-claude-native.mjs +49 -0
- package/scripts/tfx-route-worker.mjs +161 -0
- package/scripts/tfx-route.sh +943 -508
- package/skills/tfx-auto/SKILL.md +90 -564
- package/skills/tfx-auto-codex/SKILL.md +77 -0
- package/skills/tfx-codex/SKILL.md +1 -4
- package/skills/tfx-doctor/SKILL.md +1 -0
- package/skills/tfx-gemini/SKILL.md +1 -4
- package/skills/tfx-multi/SKILL.md +296 -0
- package/skills/tfx-setup/SKILL.md +1 -4
- package/skills/tfx-team/SKILL.md +0 -172
package/hub/team/dashboard.mjs
CHANGED
|
@@ -1,46 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hub/team/dashboard.mjs — 실시간 팀 상태 표시
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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]
|
|
5
8
|
import { get } from "node:http";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
// ── 색상 ──
|
|
9
|
-
const AMBER = "\x1b[38;5;214m";
|
|
10
|
-
const GREEN = "\x1b[38;5;82m";
|
|
11
|
-
const RED = "\x1b[38;5;196m";
|
|
12
|
-
const GRAY = "\x1b[38;5;245m";
|
|
13
|
-
const DIM = "\x1b[2m";
|
|
14
|
-
const BOLD = "\x1b[1m";
|
|
15
|
-
const RESET = "\x1b[0m";
|
|
9
|
+
import { AMBER, GREEN, RED, GRAY, DIM, BOLD, RESET } from "./shared.mjs";
|
|
16
10
|
|
|
17
11
|
/**
|
|
18
|
-
*
|
|
19
|
-
* @param {string}
|
|
12
|
+
* HTTP GET JSON
|
|
13
|
+
* @param {string} url
|
|
20
14
|
* @returns {Promise<object|null>}
|
|
21
15
|
*/
|
|
22
|
-
function
|
|
16
|
+
function fetchJson(url) {
|
|
23
17
|
return new Promise((resolve) => {
|
|
24
|
-
const url = `${hubUrl}/status`;
|
|
25
18
|
const req = get(url, { timeout: 2000 }, (res) => {
|
|
26
19
|
let data = "";
|
|
27
20
|
res.on("data", (chunk) => (data += chunk));
|
|
28
21
|
res.on("end", () => {
|
|
29
|
-
try {
|
|
30
|
-
resolve(JSON.parse(data));
|
|
31
|
-
} catch {
|
|
32
|
-
resolve(null);
|
|
33
|
-
}
|
|
22
|
+
try { resolve(JSON.parse(data)); } catch { resolve(null); }
|
|
34
23
|
});
|
|
35
24
|
});
|
|
36
25
|
req.on("error", () => resolve(null));
|
|
37
|
-
req.on("timeout", () => {
|
|
38
|
-
req.destroy();
|
|
39
|
-
resolve(null);
|
|
40
|
-
});
|
|
26
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
41
27
|
});
|
|
42
28
|
}
|
|
43
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
|
+
|
|
44
50
|
/**
|
|
45
51
|
* 진행률 바 생성
|
|
46
52
|
* @param {number} pct — 0~100
|
|
@@ -65,56 +71,141 @@ function formatUptime(ms) {
|
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
/**
|
|
68
|
-
*
|
|
69
|
-
* @param {string}
|
|
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 — 세션 또는 팀 이름
|
|
70
146
|
* @param {object} opts
|
|
71
147
|
* @param {string} opts.hubUrl — Hub URL (기본 http://127.0.0.1:27888)
|
|
72
|
-
* @param {
|
|
148
|
+
* @param {string} [opts.teamName] — Hub task-list 조회용 팀 이름
|
|
149
|
+
* @param {string} [opts.supervisorUrl] — native-supervisor 제어 URL
|
|
150
|
+
* @param {object} [opts.teamState] — team-state.json 내용 (하위 호환)
|
|
73
151
|
*/
|
|
74
152
|
export async function renderDashboard(sessionName, opts = {}) {
|
|
75
|
-
const {
|
|
153
|
+
const {
|
|
154
|
+
hubUrl = "http://127.0.0.1:27888",
|
|
155
|
+
teamName,
|
|
156
|
+
supervisorUrl,
|
|
157
|
+
teamState = {},
|
|
158
|
+
} = opts;
|
|
76
159
|
const W = 50;
|
|
77
160
|
const border = "─".repeat(W);
|
|
78
161
|
|
|
79
|
-
//
|
|
80
|
-
const
|
|
81
|
-
|
|
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;
|
|
82
170
|
const hubState = hubOnline ? `${GREEN}● online${RESET}` : `${RED}● offline${RESET}`;
|
|
83
|
-
const uptime =
|
|
84
|
-
const queueSize =
|
|
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 || []) : [];
|
|
85
181
|
|
|
86
182
|
// 헤더
|
|
87
|
-
|
|
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}`);
|
|
88
185
|
console.log(`${AMBER}│${RESET} Hub: ${hubState} Uptime: ${DIM}${uptime}${RESET} Queue: ${DIM}${queueSize}${RESET}`);
|
|
89
186
|
console.log(`${AMBER}│${RESET}`);
|
|
90
187
|
|
|
91
|
-
//
|
|
92
|
-
const
|
|
93
|
-
const paneEntries = Object.entries(panes).filter(([, v]) => v.role !== "dashboard");
|
|
188
|
+
// 멤버/워커 렌더링
|
|
189
|
+
const members = buildMemberList(hubTasks, supervisorMembers, teamState);
|
|
94
190
|
|
|
95
|
-
if (
|
|
191
|
+
if (members.length === 0) {
|
|
96
192
|
console.log(`${AMBER}│${RESET} ${DIM}에이전트 정보 없음${RESET}`);
|
|
97
193
|
} else {
|
|
98
|
-
for (const
|
|
99
|
-
const
|
|
100
|
-
const label = `[${
|
|
101
|
-
const cliTag = cli.charAt(0).toUpperCase() + cli.slice(1);
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// pane 미리보기 (마지막 2줄)
|
|
114
|
-
const preview = capturePaneOutput(paneTarget, 2)
|
|
115
|
-
.split("\n")
|
|
116
|
-
.filter(Boolean)
|
|
117
|
-
.slice(-1)[0] || "";
|
|
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 || "";
|
|
118
209
|
if (preview) {
|
|
119
210
|
const truncated = preview.length > W - 8 ? preview.slice(0, W - 11) + "..." : preview;
|
|
120
211
|
console.log(`${AMBER}│${RESET} ${DIM}> ${truncated}${RESET}`);
|
|
@@ -140,14 +231,17 @@ async function loadTeamState() {
|
|
|
140
231
|
}
|
|
141
232
|
}
|
|
142
233
|
|
|
143
|
-
// ── CLI 실행
|
|
234
|
+
// ── CLI 실행 ──
|
|
144
235
|
if (process.argv[1]?.includes("dashboard.mjs")) {
|
|
145
236
|
const sessionIdx = process.argv.indexOf("--session");
|
|
237
|
+
const teamIdx = process.argv.indexOf("--team");
|
|
146
238
|
const sessionName = sessionIdx !== -1 ? process.argv[sessionIdx + 1] : null;
|
|
239
|
+
const teamName = teamIdx !== -1 ? process.argv[teamIdx + 1] : null;
|
|
147
240
|
const intervalSec = parseInt(process.argv[process.argv.indexOf("--interval") + 1] || "2", 10);
|
|
148
241
|
|
|
149
|
-
|
|
150
|
-
|
|
242
|
+
const displayName = sessionName || teamName;
|
|
243
|
+
if (!displayName) {
|
|
244
|
+
console.error("사용법: node dashboard.mjs --session <세션이름> [--team <팀이름>] [--interval 2]");
|
|
151
245
|
process.exit(1);
|
|
152
246
|
}
|
|
153
247
|
|
|
@@ -157,9 +251,16 @@ if (process.argv[1]?.includes("dashboard.mjs")) {
|
|
|
157
251
|
// 갱신 루프
|
|
158
252
|
while (true) {
|
|
159
253
|
const teamState = await loadTeamState();
|
|
254
|
+
const effectiveTeamName = teamName || null;
|
|
255
|
+
const supervisorUrl = teamState?.native?.controlUrl || null;
|
|
256
|
+
|
|
160
257
|
// 화면 클리어 (ANSI)
|
|
161
258
|
process.stdout.write("\x1b[2J\x1b[H");
|
|
162
|
-
await renderDashboard(
|
|
259
|
+
await renderDashboard(displayName, {
|
|
260
|
+
teamName: effectiveTeamName,
|
|
261
|
+
supervisorUrl,
|
|
262
|
+
teamState,
|
|
263
|
+
});
|
|
163
264
|
console.log(`${DIM} ${intervalSec}초 간격 갱신 | Ctrl+C로 종료${RESET}`);
|
|
164
265
|
await new Promise((r) => setTimeout(r, intervalSec * 1000));
|
|
165
266
|
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// hub/team/native-supervisor.mjs — tmux 없이 멀티 CLI를 직접 띄우는 네이티브 팀 런타임
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync, createWriteStream } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
function parseArgs(argv) {
|
|
8
|
+
const out = {};
|
|
9
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10
|
+
const cur = argv[i];
|
|
11
|
+
if (cur === "--config" && argv[i + 1]) {
|
|
12
|
+
out.config = argv[++i];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readJson(path) {
|
|
19
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function safeText(v, fallback = "") {
|
|
23
|
+
if (v == null) return fallback;
|
|
24
|
+
return String(v);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function nowMs() {
|
|
28
|
+
return Date.now();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const args = parseArgs(process.argv.slice(2));
|
|
32
|
+
if (!args.config) {
|
|
33
|
+
console.error("사용법: node native-supervisor.mjs --config <path>");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const config = await readJson(args.config);
|
|
38
|
+
const {
|
|
39
|
+
sessionName,
|
|
40
|
+
runtimeFile,
|
|
41
|
+
logsDir,
|
|
42
|
+
startupDelayMs = 3000,
|
|
43
|
+
members = [],
|
|
44
|
+
} = config;
|
|
45
|
+
|
|
46
|
+
mkdirSync(logsDir, { recursive: true });
|
|
47
|
+
mkdirSync(dirname(runtimeFile), { recursive: true });
|
|
48
|
+
|
|
49
|
+
const startedAt = nowMs();
|
|
50
|
+
const processMap = new Map();
|
|
51
|
+
|
|
52
|
+
function memberStateSnapshot() {
|
|
53
|
+
const states = [];
|
|
54
|
+
for (const m of members) {
|
|
55
|
+
const state = processMap.get(m.name);
|
|
56
|
+
states.push({
|
|
57
|
+
name: m.name,
|
|
58
|
+
role: m.role,
|
|
59
|
+
cli: m.cli,
|
|
60
|
+
agentId: m.agentId,
|
|
61
|
+
command: m.command,
|
|
62
|
+
pid: state?.child?.pid || null,
|
|
63
|
+
status: state?.status || "unknown",
|
|
64
|
+
exitCode: state?.exitCode ?? null,
|
|
65
|
+
lastPreview: state?.lastPreview || "",
|
|
66
|
+
logFile: state?.logFile || null,
|
|
67
|
+
errFile: state?.errFile || null,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return states;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeRuntime(controlPort) {
|
|
74
|
+
const runtime = {
|
|
75
|
+
sessionName,
|
|
76
|
+
supervisorPid: process.pid,
|
|
77
|
+
controlUrl: `http://127.0.0.1:${controlPort}`,
|
|
78
|
+
startedAt,
|
|
79
|
+
members: memberStateSnapshot(),
|
|
80
|
+
};
|
|
81
|
+
writeFileSync(runtimeFile, JSON.stringify(runtime, null, 2) + "\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function spawnMember(member) {
|
|
85
|
+
const outPath = join(logsDir, `${member.name}.out.log`);
|
|
86
|
+
const errPath = join(logsDir, `${member.name}.err.log`);
|
|
87
|
+
|
|
88
|
+
const outWs = createWriteStream(outPath, { flags: "a" });
|
|
89
|
+
const errWs = createWriteStream(errPath, { flags: "a" });
|
|
90
|
+
|
|
91
|
+
const child = spawn(member.command, {
|
|
92
|
+
shell: true,
|
|
93
|
+
env: {
|
|
94
|
+
...process.env,
|
|
95
|
+
TERM: process.env.TERM && process.env.TERM !== "dumb" ? process.env.TERM : "xterm-256color",
|
|
96
|
+
},
|
|
97
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
98
|
+
windowsHide: true,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const state = {
|
|
102
|
+
member,
|
|
103
|
+
child,
|
|
104
|
+
outWs,
|
|
105
|
+
errWs,
|
|
106
|
+
logFile: outPath,
|
|
107
|
+
errFile: errPath,
|
|
108
|
+
status: "running",
|
|
109
|
+
exitCode: null,
|
|
110
|
+
lastPreview: "",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
child.stdout.on("data", (buf) => {
|
|
114
|
+
outWs.write(buf);
|
|
115
|
+
const txt = safeText(buf).trim();
|
|
116
|
+
if (txt) {
|
|
117
|
+
const lines = txt.split(/\r?\n/).filter(Boolean);
|
|
118
|
+
if (lines.length) state.lastPreview = lines[lines.length - 1].slice(0, 280);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
child.stderr.on("data", (buf) => {
|
|
123
|
+
errWs.write(buf);
|
|
124
|
+
const txt = safeText(buf).trim();
|
|
125
|
+
if (txt) {
|
|
126
|
+
const lines = txt.split(/\r?\n/).filter(Boolean);
|
|
127
|
+
if (lines.length) state.lastPreview = `[err] ${lines[lines.length - 1].slice(0, 260)}`;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
child.on("exit", (code) => {
|
|
132
|
+
state.status = "exited";
|
|
133
|
+
state.exitCode = code;
|
|
134
|
+
try { outWs.end(); } catch {}
|
|
135
|
+
try { errWs.end(); } catch {}
|
|
136
|
+
maybeAutoShutdown();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
processMap.set(member.name, state);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function sendInput(memberName, text) {
|
|
143
|
+
const state = processMap.get(memberName);
|
|
144
|
+
if (!state) return { ok: false, error: "member_not_found" };
|
|
145
|
+
if (state.status !== "running") return { ok: false, error: "member_not_running" };
|
|
146
|
+
try {
|
|
147
|
+
state.child.stdin.write(`${safeText(text)}\n`);
|
|
148
|
+
return { ok: true };
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return { ok: false, error: e.message };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function interruptMember(memberName) {
|
|
155
|
+
const state = processMap.get(memberName);
|
|
156
|
+
if (!state) return { ok: false, error: "member_not_found" };
|
|
157
|
+
if (state.status !== "running") return { ok: false, error: "member_not_running" };
|
|
158
|
+
|
|
159
|
+
let signaled = false;
|
|
160
|
+
try {
|
|
161
|
+
signaled = state.child.kill("SIGINT");
|
|
162
|
+
} catch {
|
|
163
|
+
signaled = false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!signaled) {
|
|
167
|
+
try {
|
|
168
|
+
state.child.stdin.write("\u0003");
|
|
169
|
+
signaled = true;
|
|
170
|
+
} catch {
|
|
171
|
+
signaled = false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return signaled ? { ok: true } : { ok: false, error: "interrupt_failed" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let isShuttingDown = false;
|
|
179
|
+
|
|
180
|
+
function maybeAutoShutdown() {
|
|
181
|
+
if (isShuttingDown) return;
|
|
182
|
+
const allExited = [...processMap.values()].every((s) => s.status === "exited");
|
|
183
|
+
if (!allExited) return;
|
|
184
|
+
shutdown();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function shutdown() {
|
|
188
|
+
if (isShuttingDown) return;
|
|
189
|
+
isShuttingDown = true;
|
|
190
|
+
|
|
191
|
+
for (const state of processMap.values()) {
|
|
192
|
+
if (state.status === "running") {
|
|
193
|
+
try { state.child.stdin.write("exit\n"); } catch {}
|
|
194
|
+
try { state.child.kill("SIGTERM"); } catch {}
|
|
195
|
+
}
|
|
196
|
+
try { state.outWs.end(); } catch {}
|
|
197
|
+
try { state.errWs.end(); } catch {}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
setTimeout(() => {
|
|
201
|
+
for (const state of processMap.values()) {
|
|
202
|
+
if (state.status === "running") {
|
|
203
|
+
try { state.child.kill("SIGKILL"); } catch {}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}, 1200).unref();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const member of members) {
|
|
211
|
+
spawnMember(member);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const server = createServer(async (req, res) => {
|
|
215
|
+
const send = (code, obj) => {
|
|
216
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
217
|
+
res.end(JSON.stringify(obj));
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (req.method === "GET" && (req.url === "/" || req.url === "/status")) {
|
|
221
|
+
return send(200, {
|
|
222
|
+
ok: true,
|
|
223
|
+
data: {
|
|
224
|
+
sessionName,
|
|
225
|
+
supervisorPid: process.pid,
|
|
226
|
+
uptimeMs: nowMs() - startedAt,
|
|
227
|
+
members: memberStateSnapshot(),
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (req.method !== "POST") {
|
|
233
|
+
return send(405, { ok: false, error: "method_not_allowed" });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let body = {};
|
|
237
|
+
try {
|
|
238
|
+
const chunks = [];
|
|
239
|
+
for await (const c of req) chunks.push(c);
|
|
240
|
+
const raw = Buffer.concat(chunks).toString("utf8") || "{}";
|
|
241
|
+
body = JSON.parse(raw);
|
|
242
|
+
} catch {
|
|
243
|
+
return send(400, { ok: false, error: "invalid_json" });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (req.url === "/send") {
|
|
247
|
+
const { member, text } = body;
|
|
248
|
+
const r = sendInput(member, text);
|
|
249
|
+
return send(r.ok ? 200 : 400, r);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (req.url === "/interrupt") {
|
|
253
|
+
const { member } = body;
|
|
254
|
+
const r = interruptMember(member);
|
|
255
|
+
return send(r.ok ? 200 : 400, r);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (req.url === "/control") {
|
|
259
|
+
const { member, command = "", reason = "" } = body;
|
|
260
|
+
const controlMsg = `[LEAD CONTROL] command=${command}${reason ? ` reason=${reason}` : ""}`;
|
|
261
|
+
const a = sendInput(member, controlMsg);
|
|
262
|
+
if (!a.ok) return send(400, a);
|
|
263
|
+
if (String(command).toLowerCase() === "interrupt") {
|
|
264
|
+
const b = interruptMember(member);
|
|
265
|
+
if (!b.ok) return send(400, b);
|
|
266
|
+
}
|
|
267
|
+
return send(200, { ok: true });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (req.url === "/stop") {
|
|
271
|
+
send(200, { ok: true });
|
|
272
|
+
shutdown();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return send(404, { ok: false, error: "not_found" });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
server.listen(0, "127.0.0.1", () => {
|
|
280
|
+
const address = server.address();
|
|
281
|
+
const port = typeof address === "object" && address ? address.port : null;
|
|
282
|
+
if (!port) {
|
|
283
|
+
console.error("native supervisor 포트 바인딩 실패");
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
writeRuntime(port);
|
|
288
|
+
|
|
289
|
+
// CLI 초기화 후 프롬프트 주입
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
for (const m of members) {
|
|
292
|
+
if (m.prompt) {
|
|
293
|
+
sendInput(m.name, m.prompt);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}, startupDelayMs).unref();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
process.on("SIGINT", shutdown);
|
|
300
|
+
process.on("SIGTERM", shutdown);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// hub/team/native.mjs — Claude Native Teams 래퍼
|
|
2
|
+
// teammate 프롬프트 템플릿 + 팀 설정 빌더
|
|
3
|
+
//
|
|
4
|
+
// Claude Code 네이티브 Agent Teams (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1)
|
|
5
|
+
// 환경에서 teammate를 Codex/Gemini CLI 래퍼로 구성하는 유틸리티.
|
|
6
|
+
// SKILL.md가 인라인 프롬프트를 사용하므로, 이 모듈은 CLI(tfx multi --native)에서
|
|
7
|
+
// 팀 설정을 프로그래밍적으로 생성할 때 사용한다.
|
|
8
|
+
|
|
9
|
+
const ROUTE_SCRIPT = "~/.claude/scripts/tfx-route.sh";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* v2.2 슬림 래퍼 프롬프트 생성
|
|
13
|
+
* Agent spawn으로 네비게이션에 등록하되, 실제 작업은 tfx-route.sh가 수행.
|
|
14
|
+
* 프롬프트 ~100 토큰 목표 (v2의 ~500 대비 80% 감소).
|
|
15
|
+
*
|
|
16
|
+
* @param {'codex'|'gemini'} cli — CLI 타입
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {string} opts.subtask — 서브태스크 설명
|
|
19
|
+
* @param {string} [opts.role] — 역할 (executor, designer, reviewer 등)
|
|
20
|
+
* @param {string} [opts.teamName] — 팀 이름
|
|
21
|
+
* @param {string} [opts.taskId] — Hub task ID
|
|
22
|
+
* @param {string} [opts.agentName] — 워커 표시 이름
|
|
23
|
+
* @param {string} [opts.leadName] — 리드 수신자 이름
|
|
24
|
+
* @param {string} [opts.mcp_profile] — MCP 프로필
|
|
25
|
+
* @returns {string} 슬림 래퍼 프롬프트
|
|
26
|
+
*/
|
|
27
|
+
export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
28
|
+
const {
|
|
29
|
+
subtask,
|
|
30
|
+
role = "executor",
|
|
31
|
+
teamName = "tfx-multi",
|
|
32
|
+
taskId = "",
|
|
33
|
+
agentName = "",
|
|
34
|
+
leadName = "team-lead",
|
|
35
|
+
mcp_profile = "auto",
|
|
36
|
+
} = opts;
|
|
37
|
+
|
|
38
|
+
// 셸 이스케이프
|
|
39
|
+
const escaped = subtask.replace(/'/g, "'\\''");
|
|
40
|
+
|
|
41
|
+
return `Bash 1회 실행 후 반드시 종료하라. 어떤 경우에도 hang하지 마라.
|
|
42
|
+
gemini/codex를 직접 호출하지 마라. 반드시 tfx-route.sh를 거쳐야 한다.
|
|
43
|
+
프롬프트를 파일로 저장하지 마라. tfx-route.sh가 인자로 받는다.
|
|
44
|
+
|
|
45
|
+
TFX_TEAM_NAME="${teamName}" TFX_TEAM_TASK_ID="${taskId}" TFX_TEAM_AGENT_NAME="${agentName}" TFX_TEAM_LEAD_NAME="${leadName}" bash ${ROUTE_SCRIPT} "${role}" '${escaped}' ${mcp_profile}
|
|
46
|
+
|
|
47
|
+
성공 → TaskUpdate(status: completed, metadata: {result: "success"}) + SendMessage(to: ${leadName}).
|
|
48
|
+
실패 → TaskUpdate(status: completed, metadata: {result: "failed", error: "에러 요약"}) + SendMessage(to: ${leadName}).
|
|
49
|
+
|
|
50
|
+
중요: TaskUpdate의 status는 "completed"만 사용. "failed"는 API 미지원.
|
|
51
|
+
실패 여부는 metadata.result로 구분. Bash 실패 시에도 반드시 TaskUpdate + SendMessage 후 종료.`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 팀 이름 생성 (타임스탬프 기반)
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function generateTeamName() {
|
|
59
|
+
const ts = Date.now().toString(36).slice(-4);
|
|
60
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
61
|
+
return `tfx-${ts}${rand}`;
|
|
62
|
+
}
|