triflux 3.2.0-dev.1 → 3.2.0-dev.3

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.
@@ -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,92 @@
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 team --native)에서
7
+ // 팀 설정을 프로그래밍적으로 생성할 때 사용한다.
8
+
9
+ const ROUTE_SCRIPT = "~/.claude/scripts/tfx-route.sh";
10
+
11
+ /**
12
+ * CLI 타입별 teammate 프롬프트 생성
13
+ * @param {'codex'|'gemini'|'claude'} cli — CLI 타입
14
+ * @param {object} opts
15
+ * @param {string} opts.subtask — 서브태스크 설명
16
+ * @param {string} [opts.role] — 역할 (executor, designer, reviewer 등)
17
+ * @param {string} [opts.teamName] — 팀 이름
18
+ * @returns {string} teammate 프롬프트
19
+ */
20
+ export function buildTeammatePrompt(cli, opts = {}) {
21
+ const { subtask, role = "executor", teamName = "tfx-team" } = opts;
22
+
23
+ if (cli === "claude") {
24
+ return `너는 ${teamName}의 Claude 워커이다.
25
+
26
+ [작업] ${subtask}
27
+
28
+ [실행]
29
+ 1. TaskList에서 pending 작업을 확인하고 claim (TaskUpdate: in_progress)
30
+ 2. Glob, Grep, Read, Bash 등 도구로 직접 수행
31
+ 3. 완료 시 TaskUpdate(status: completed) + SendMessage로 리드에게 보고
32
+ 4. 추가 작업이 있으면 반복
33
+
34
+ 에러 시 TaskUpdate(status: failed) + SendMessage로 보고.`;
35
+ }
36
+
37
+ const label = cli === "codex" ? "Codex" : "Gemini";
38
+ const escaped = subtask.replace(/'/g, "'\\''");
39
+
40
+ return `너는 ${teamName}의 ${label} 워커이다.
41
+
42
+ [작업] ${subtask}
43
+
44
+ [실행]
45
+ 1. TaskList에서 pending 작업을 확인하고 claim (TaskUpdate: in_progress)
46
+ 2. Bash("bash ${ROUTE_SCRIPT} ${role} '${escaped}' auto")로 실행
47
+ 3. 결과 확인 후 TaskUpdate(status: completed) + SendMessage로 리드에게 보고
48
+ 4. 추가 pending 작업이 있으면 반복
49
+
50
+ [규칙]
51
+ - 실제 구현은 ${label} CLI가 수행 — 너는 실행+보고 역할
52
+ - 에러 시 TaskUpdate(status: failed) + SendMessage로 보고`;
53
+ }
54
+
55
+ /**
56
+ * teammate 이름 생성
57
+ * @param {'codex'|'gemini'|'claude'} cli
58
+ * @param {number} index — 0-based
59
+ * @returns {string}
60
+ */
61
+ export function buildTeammateName(cli, index) {
62
+ return `${cli}-worker-${index + 1}`;
63
+ }
64
+
65
+ /**
66
+ * 트리아지 결과에서 팀 멤버 설정 생성
67
+ * @param {string} teamName — 팀 이름
68
+ * @param {Array<{cli: string, subtask: string, role?: string}>} assignments
69
+ * @returns {{ name: string, members: Array<{name: string, cli: string, prompt: string}> }}
70
+ */
71
+ export function buildTeamConfig(teamName, assignments) {
72
+ return {
73
+ name: teamName,
74
+ members: assignments.map((a, i) => ({
75
+ name: buildTeammateName(a.cli, i),
76
+ cli: a.cli,
77
+ prompt: buildTeammatePrompt(a.cli, {
78
+ subtask: a.subtask,
79
+ role: a.role || "executor",
80
+ teamName,
81
+ }),
82
+ })),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * 팀 이름 생성 (타임스탬프 기반)
88
+ * @returns {string}
89
+ */
90
+ export function generateTeamName() {
91
+ return `tfx-${Date.now().toString(36).slice(-6)}`;
92
+ }