triflux 3.1.0-dev.5 → 3.2.0-dev.2

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,455 @@
1
+ // hub/team/nativeProxy.mjs
2
+ // Claude Native Teams 파일을 Hub tool/REST에서 안전하게 읽고 쓰기 위한 유틸.
3
+
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ statSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ openSync,
14
+ closeSync,
15
+ } from 'node:fs';
16
+ import { basename, dirname, join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { randomUUID } from 'node:crypto';
19
+
20
+ const TEAM_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
21
+ const CLAUDE_HOME = join(homedir(), '.claude');
22
+ const TEAMS_ROOT = join(CLAUDE_HOME, 'teams');
23
+ const TASKS_ROOT = join(CLAUDE_HOME, 'tasks');
24
+
25
+ function err(code, message, extra = {}) {
26
+ return { ok: false, error: { code, message, ...extra } };
27
+ }
28
+
29
+ function validateTeamName(teamName) {
30
+ if (!TEAM_NAME_RE.test(String(teamName || ''))) {
31
+ throw new Error('INVALID_TEAM_NAME');
32
+ }
33
+ }
34
+
35
+ function readJsonSafe(path) {
36
+ try {
37
+ return JSON.parse(readFileSync(path, 'utf8'));
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function atomicWriteJson(path, value) {
44
+ mkdirSync(dirname(path), { recursive: true });
45
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
46
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
47
+ renameSync(tmp, path);
48
+ }
49
+
50
+ function sleepMs(ms) {
51
+ const end = Date.now() + ms;
52
+ while (Date.now() < end) {}
53
+ }
54
+
55
+ function withFileLock(lockPath, fn, retries = 20, delayMs = 25) {
56
+ let fd = null;
57
+ for (let i = 0; i < retries; i += 1) {
58
+ try {
59
+ fd = openSync(lockPath, 'wx');
60
+ break;
61
+ } catch (e) {
62
+ if (e?.code !== 'EEXIST' || i === retries - 1) throw e;
63
+ sleepMs(delayMs);
64
+ }
65
+ }
66
+
67
+ try {
68
+ return fn();
69
+ } finally {
70
+ try { if (fd != null) closeSync(fd); } catch {}
71
+ try { unlinkSync(lockPath); } catch {}
72
+ }
73
+ }
74
+
75
+ function getLeadSessionId(config) {
76
+ return config?.leadSessionId
77
+ || config?.lead_session_id
78
+ || config?.lead?.lead_session_id
79
+ || config?.lead?.sessionId
80
+ || null;
81
+ }
82
+
83
+ export function resolveTeamPaths(teamName) {
84
+ validateTeamName(teamName);
85
+
86
+ const teamDir = join(TEAMS_ROOT, teamName);
87
+ const configPath = join(teamDir, 'config.json');
88
+ const inboxesDir = join(teamDir, 'inboxes');
89
+ const config = readJsonSafe(configPath);
90
+ const leadSessionId = getLeadSessionId(config);
91
+
92
+ const byTeam = join(TASKS_ROOT, teamName);
93
+ const byLeadSession = leadSessionId ? join(TASKS_ROOT, leadSessionId) : null;
94
+
95
+ let tasksDir = byTeam;
96
+ let tasksDirResolution = 'not_found';
97
+ if (existsSync(byTeam)) {
98
+ tasksDirResolution = 'team_name';
99
+ } else if (byLeadSession && existsSync(byLeadSession)) {
100
+ tasksDir = byLeadSession;
101
+ tasksDirResolution = 'lead_session_id';
102
+ }
103
+
104
+ return {
105
+ team_dir: teamDir,
106
+ config_path: configPath,
107
+ inboxes_dir: inboxesDir,
108
+ tasks_dir: tasksDir,
109
+ tasks_dir_resolution: tasksDirResolution,
110
+ lead_session_id: leadSessionId,
111
+ config,
112
+ };
113
+ }
114
+
115
+ function collectTaskFiles(tasksDir) {
116
+ if (!existsSync(tasksDir)) return [];
117
+ return readdirSync(tasksDir)
118
+ .filter((name) => name.endsWith('.json'))
119
+ .filter((name) => !name.endsWith('.lock'))
120
+ .filter((name) => name !== '.highwatermark')
121
+ .map((name) => join(tasksDir, name));
122
+ }
123
+
124
+ function locateTaskFile(tasksDir, taskId) {
125
+ const direct = join(tasksDir, `${taskId}.json`);
126
+ if (existsSync(direct)) return direct;
127
+
128
+ const files = collectTaskFiles(tasksDir);
129
+ for (const file of files) {
130
+ if (basename(file, '.json') === taskId) return file;
131
+ const json = readJsonSafe(file);
132
+ if (json && String(json.id || '') === taskId) return file;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function isObject(v) {
138
+ return !!v && typeof v === 'object' && !Array.isArray(v);
139
+ }
140
+
141
+ export function teamInfo(args = {}) {
142
+ const { team_name, include_members = true, include_paths = true } = args;
143
+ try {
144
+ validateTeamName(team_name);
145
+ } catch {
146
+ return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
147
+ }
148
+
149
+ const paths = resolveTeamPaths(team_name);
150
+ if (!existsSync(paths.team_dir)) {
151
+ return err('TEAM_NOT_FOUND', `팀 디렉토리가 없습니다: ${paths.team_dir}`);
152
+ }
153
+
154
+ const members = Array.isArray(paths.config?.members) ? paths.config.members : [];
155
+ const leadAgentId = paths.config?.leadAgentId
156
+ || paths.config?.lead_agent_id
157
+ || members[0]?.agentId
158
+ || null;
159
+
160
+ return {
161
+ ok: true,
162
+ data: {
163
+ team: {
164
+ team_name,
165
+ description: paths.config?.description || null,
166
+ },
167
+ lead: {
168
+ lead_agent_id: leadAgentId,
169
+ lead_session_id: paths.lead_session_id,
170
+ },
171
+ ...(include_members ? { members } : {}),
172
+ ...(include_paths ? {
173
+ paths: {
174
+ config_path: paths.config_path,
175
+ tasks_dir: paths.tasks_dir,
176
+ inboxes_dir: paths.inboxes_dir,
177
+ tasks_dir_resolution: paths.tasks_dir_resolution,
178
+ },
179
+ } : {}),
180
+ },
181
+ };
182
+ }
183
+
184
+ export function teamTaskList(args = {}) {
185
+ const {
186
+ team_name,
187
+ owner,
188
+ statuses = [],
189
+ include_internal = false,
190
+ limit = 200,
191
+ } = args;
192
+
193
+ try {
194
+ validateTeamName(team_name);
195
+ } catch {
196
+ return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
197
+ }
198
+
199
+ const paths = resolveTeamPaths(team_name);
200
+ if (paths.tasks_dir_resolution === 'not_found') {
201
+ return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
202
+ }
203
+
204
+ const statusSet = new Set((statuses || []).map((s) => String(s)));
205
+ const maxCount = Math.max(1, Math.min(Number(limit) || 200, 1000));
206
+ let parseWarnings = 0;
207
+
208
+ const tasks = [];
209
+ for (const file of collectTaskFiles(paths.tasks_dir)) {
210
+ const json = readJsonSafe(file);
211
+ if (!json || !isObject(json)) {
212
+ parseWarnings += 1;
213
+ continue;
214
+ }
215
+
216
+ if (!include_internal && json?.metadata?._internal === true) continue;
217
+ if (owner && String(json.owner || '') !== String(owner)) continue;
218
+ if (statusSet.size > 0 && !statusSet.has(String(json.status || ''))) continue;
219
+
220
+ let mtime = Date.now();
221
+ try { mtime = statSync(file).mtimeMs; } catch {}
222
+
223
+ tasks.push({
224
+ ...json,
225
+ task_file: file,
226
+ mtime_ms: mtime,
227
+ });
228
+ }
229
+
230
+ tasks.sort((a, b) => Number(b.mtime_ms || 0) - Number(a.mtime_ms || 0));
231
+ const sliced = tasks.slice(0, maxCount);
232
+
233
+ return {
234
+ ok: true,
235
+ data: {
236
+ tasks: sliced,
237
+ count: sliced.length,
238
+ parse_warnings: parseWarnings,
239
+ tasks_dir: paths.tasks_dir,
240
+ tasks_dir_resolution: paths.tasks_dir_resolution,
241
+ },
242
+ };
243
+ }
244
+
245
+ export function teamTaskUpdate(args = {}) {
246
+ const {
247
+ team_name,
248
+ task_id,
249
+ claim = false,
250
+ owner,
251
+ status,
252
+ subject,
253
+ description,
254
+ activeForm,
255
+ add_blocks = [],
256
+ add_blocked_by = [],
257
+ metadata_patch,
258
+ if_match_mtime_ms,
259
+ actor,
260
+ } = args;
261
+
262
+ try {
263
+ validateTeamName(team_name);
264
+ } catch {
265
+ return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
266
+ }
267
+
268
+ if (!String(task_id || '').trim()) {
269
+ return err('INVALID_TASK_ID', 'task_id가 필요합니다');
270
+ }
271
+
272
+ const paths = resolveTeamPaths(team_name);
273
+ if (paths.tasks_dir_resolution === 'not_found') {
274
+ return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
275
+ }
276
+
277
+ const taskFile = locateTaskFile(paths.tasks_dir, String(task_id));
278
+ if (!taskFile) {
279
+ return err('TASK_NOT_FOUND', `task를 찾지 못했습니다: ${task_id}`);
280
+ }
281
+
282
+ const lockFile = `${taskFile}.lock`;
283
+
284
+ try {
285
+ return withFileLock(lockFile, () => {
286
+ const before = readJsonSafe(taskFile);
287
+ if (!before || !isObject(before)) {
288
+ return err('INVALID_TASK_FILE', `task 파일 파싱 실패: ${taskFile}`);
289
+ }
290
+
291
+ let beforeMtime = Date.now();
292
+ try { beforeMtime = statSync(taskFile).mtimeMs; } catch {}
293
+
294
+ if (if_match_mtime_ms != null && Number(if_match_mtime_ms) !== Number(beforeMtime)) {
295
+ return err('MTIME_CONFLICT', 'if_match_mtime_ms가 일치하지 않습니다', {
296
+ task_file: taskFile,
297
+ mtime_ms: beforeMtime,
298
+ });
299
+ }
300
+
301
+ const after = JSON.parse(JSON.stringify(before));
302
+ let claimed = false;
303
+ let updated = false;
304
+
305
+ if (claim) {
306
+ const requestedOwner = String(owner || actor || '');
307
+ const ownerNow = String(before.owner || '');
308
+ const ownerCompatible = ownerNow === '' || requestedOwner === '' || ownerNow === requestedOwner;
309
+ const statusPending = String(before.status || '') === 'pending';
310
+
311
+ if (!statusPending || !ownerCompatible) {
312
+ return err('CLAIM_CONFLICT', 'task claim 충돌', {
313
+ task_before: before,
314
+ task_file: taskFile,
315
+ mtime_ms: beforeMtime,
316
+ });
317
+ }
318
+
319
+ if (requestedOwner) after.owner = requestedOwner;
320
+ after.status = status || 'in_progress';
321
+ claimed = true;
322
+ updated = true;
323
+ }
324
+
325
+ if (owner != null && String(after.owner || '') !== String(owner)) {
326
+ after.owner = owner;
327
+ updated = true;
328
+ }
329
+ if (status != null && String(after.status || '') !== String(status)) {
330
+ after.status = status;
331
+ updated = true;
332
+ }
333
+ if (subject != null && String(after.subject || '') !== String(subject)) {
334
+ after.subject = subject;
335
+ updated = true;
336
+ }
337
+ if (description != null && String(after.description || '') !== String(description)) {
338
+ after.description = description;
339
+ updated = true;
340
+ }
341
+ if (activeForm != null && String(after.activeForm || '') !== String(activeForm)) {
342
+ after.activeForm = activeForm;
343
+ updated = true;
344
+ }
345
+
346
+ if (Array.isArray(add_blocks) && add_blocks.length > 0) {
347
+ const current = Array.isArray(after.blocks) ? [...after.blocks] : [];
348
+ for (const item of add_blocks) {
349
+ if (!current.includes(item)) current.push(item);
350
+ }
351
+ after.blocks = current;
352
+ updated = true;
353
+ }
354
+
355
+ if (Array.isArray(add_blocked_by) && add_blocked_by.length > 0) {
356
+ const current = Array.isArray(after.blockedBy) ? [...after.blockedBy] : [];
357
+ for (const item of add_blocked_by) {
358
+ if (!current.includes(item)) current.push(item);
359
+ }
360
+ after.blockedBy = current;
361
+ updated = true;
362
+ }
363
+
364
+ if (isObject(metadata_patch)) {
365
+ const base = isObject(after.metadata) ? after.metadata : {};
366
+ after.metadata = { ...base, ...metadata_patch };
367
+ updated = true;
368
+ }
369
+
370
+ if (updated) {
371
+ atomicWriteJson(taskFile, after);
372
+ }
373
+
374
+ let afterMtime = beforeMtime;
375
+ try { afterMtime = statSync(taskFile).mtimeMs; } catch {}
376
+
377
+ return {
378
+ ok: true,
379
+ data: {
380
+ claimed,
381
+ updated,
382
+ task_before: before,
383
+ task_after: updated ? after : before,
384
+ task_file: taskFile,
385
+ mtime_ms: afterMtime,
386
+ },
387
+ };
388
+ });
389
+ } catch (e) {
390
+ return err('TASK_UPDATE_FAILED', e.message);
391
+ }
392
+ }
393
+
394
+ function sanitizeRecipientName(v) {
395
+ return String(v || 'team-lead').replace(/[\\/:*?"<>|]/g, '_');
396
+ }
397
+
398
+ export function teamSendMessage(args = {}) {
399
+ const {
400
+ team_name,
401
+ from,
402
+ to = 'team-lead',
403
+ text,
404
+ summary,
405
+ color = 'blue',
406
+ } = args;
407
+
408
+ try {
409
+ validateTeamName(team_name);
410
+ } catch {
411
+ return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
412
+ }
413
+
414
+ if (!String(from || '').trim()) return err('INVALID_FROM', 'from이 필요합니다');
415
+ if (!String(text || '').trim()) return err('INVALID_TEXT', 'text가 필요합니다');
416
+
417
+ const paths = resolveTeamPaths(team_name);
418
+ if (!existsSync(paths.team_dir)) {
419
+ return err('TEAM_NOT_FOUND', `팀 디렉토리가 없습니다: ${paths.team_dir}`);
420
+ }
421
+
422
+ const recipient = sanitizeRecipientName(to);
423
+ const inboxFile = join(paths.inboxes_dir, `${recipient}.json`);
424
+ const queue = readJsonSafe(inboxFile);
425
+ const list = Array.isArray(queue) ? queue : [];
426
+
427
+ const message = {
428
+ id: randomUUID(),
429
+ from: String(from),
430
+ text: String(text),
431
+ ...(summary ? { summary: String(summary) } : {}),
432
+ timestamp: new Date().toISOString(),
433
+ color: String(color || 'blue'),
434
+ read: false,
435
+ };
436
+ list.push(message);
437
+
438
+ try {
439
+ atomicWriteJson(inboxFile, list);
440
+ } catch (e) {
441
+ return err('SEND_MESSAGE_FAILED', e.message);
442
+ }
443
+
444
+ const unreadCount = list.filter((m) => m?.read !== true).length;
445
+ return {
446
+ ok: true,
447
+ data: {
448
+ message_id: message.id,
449
+ recipient,
450
+ inbox_file: inboxFile,
451
+ queued_at: message.timestamp,
452
+ unread_count: unreadCount,
453
+ },
454
+ };
455
+ }
@@ -0,0 +1,166 @@
1
+ // hub/team/orchestrator.mjs — 작업 분배 + 프롬프트 구성
2
+ // 의존성: pane.mjs만 사용
3
+ import { injectPrompt } from "./pane.mjs";
4
+
5
+ /**
6
+ * 작업 분해 (LLM 없이 구분자 기반)
7
+ * @param {string} taskDescription — 전체 작업 설명
8
+ * @param {number} agentCount — 에이전트 수
9
+ * @returns {string[]} 각 에이전트의 서브태스크
10
+ */
11
+ export function decomposeTask(taskDescription, agentCount) {
12
+ if (agentCount <= 0) return [];
13
+ if (agentCount === 1) return [taskDescription];
14
+
15
+ // '+', ',', '\n' 기준으로 분리
16
+ const parts = taskDescription
17
+ .split(/[+,\n]+/)
18
+ .map((s) => s.trim())
19
+ .filter(Boolean);
20
+
21
+ if (parts.length === 0) return [taskDescription];
22
+
23
+ // 에이전트보다 서브태스크가 적으면 마지막 에이전트에 전체 태스크 부여
24
+ if (parts.length < agentCount) {
25
+ const result = [...parts];
26
+ while (result.length < agentCount) {
27
+ result.push(taskDescription);
28
+ }
29
+ return result;
30
+ }
31
+
32
+ // 에이전트보다 서브태스크가 많으면 앞에서부터 N개, 나머지는 마지막에 합침
33
+ if (parts.length > agentCount) {
34
+ const result = parts.slice(0, agentCount - 1);
35
+ result.push(parts.slice(agentCount - 1).join(" + "));
36
+ return result;
37
+ }
38
+
39
+ return parts;
40
+ }
41
+
42
+ /**
43
+ * 리드(보통 claude) 초기 프롬프트 생성
44
+ * @param {string} taskDescription
45
+ * @param {object} config
46
+ * @param {string} config.agentId
47
+ * @param {string} config.hubUrl
48
+ * @param {string} config.teammateMode
49
+ * @param {Array<{agentId:string, cli:string, subtask:string}>} config.workers
50
+ * @returns {string}
51
+ */
52
+ export function buildLeadPrompt(taskDescription, config) {
53
+ const { agentId, hubUrl, teammateMode = "tmux", workers = [] } = config;
54
+
55
+ const roster = workers
56
+ .map((w, i) => `${i + 1}. ${w.agentId} (${w.cli}) — ${w.subtask}`)
57
+ .join("\n") || "- (워커 없음)";
58
+
59
+ const workerIds = workers.map((w) => w.agentId).join(", ");
60
+
61
+ return `리드 에이전트: ${agentId}
62
+
63
+ 목표: ${taskDescription}
64
+ 모드: ${teammateMode}
65
+
66
+ 워커:
67
+ ${roster}
68
+
69
+ 규칙:
70
+ - 가능한 짧고 핵심만 지시/요약(토큰 절약)
71
+ - 워커 제어 메시지 표준:
72
+ publish(from="${agentId}", to="<worker-agent-id>", topic="lead.control", payload={command:"interrupt|stop|pause|resume", reason:"..."})
73
+ - 워커 결과 수집:
74
+ poll_messages(agent_id="${agentId}", wait_ms=1000, max_messages=20)
75
+ - 최종 결과는 topic="task.result"를 모아 통합
76
+
77
+ 권장 워커 ID: ${workerIds || "(없음)"}
78
+ Hub: ${hubUrl}
79
+ 지금 즉시 워커를 배정하고 병렬 진행을 관리하라.`;
80
+ }
81
+
82
+ /**
83
+ * 워커 초기 프롬프트 생성
84
+ * @param {string} subtask — 이 에이전트의 서브태스크
85
+ * @param {object} config
86
+ * @param {string} config.cli — codex/gemini/claude
87
+ * @param {string} config.agentId — 에이전트 식별자
88
+ * @param {string} config.hubUrl — Hub URL
89
+ * @returns {string}
90
+ */
91
+ export function buildPrompt(subtask, config) {
92
+ const { cli, agentId, hubUrl } = config;
93
+
94
+ const hubBase = hubUrl.replace("/mcp", "");
95
+
96
+ return `워커: ${agentId} (${cli})
97
+ 작업: ${subtask}
98
+
99
+ 필수 규칙:
100
+ 1) 간결하게 작업(불필요한 장문 설명 금지)
101
+ 2) 시작 즉시 register 실행:
102
+ register(agent_id="${agentId}", cli="${cli}", capabilities=["code"], topics=["lead.control","task.result"], heartbeat_ttl_ms=600000)
103
+ 3) 주기적으로 수신함 확인:
104
+ poll_messages(agent_id="${agentId}", wait_ms=1000, max_messages=10)
105
+ 4) lead.control 수신 시 즉시 반응
106
+ - interrupt: 즉시 중단, 진행상태 요약 publish
107
+ - stop: 작업 종료, 최종 요약 publish 후 대기
108
+ - pause: 작업 일시정지
109
+ - resume: 작업 재개
110
+ 5) 완료 시 결과 발행:
111
+ publish(from="${agentId}", to="topic:task.result", topic="task.result", payload={summary:"..."})
112
+
113
+ MCP가 없으면 REST 폴백:
114
+ - POST ${hubBase}/bridge/register
115
+ - POST ${hubBase}/bridge/result
116
+
117
+ 지금 작업을 시작하라.`;
118
+ }
119
+
120
+ /**
121
+ * 팀 오케스트레이션 실행 — 각 pane에 프롬프트 주입
122
+ * @param {string} sessionName — tmux 세션 이름
123
+ * @param {Array<{target: string, cli: string, subtask: string}>} assignments
124
+ * @param {object} opts
125
+ * @param {string} opts.hubUrl — Hub URL
126
+ * @param {{target:string, cli:string, task:string}|null} opts.lead
127
+ * @param {string} opts.teammateMode
128
+ * @returns {Promise<void>}
129
+ */
130
+ export async function orchestrate(sessionName, assignments, opts = {}) {
131
+ const {
132
+ hubUrl = "http://127.0.0.1:27888/mcp",
133
+ lead = null,
134
+ teammateMode = "tmux",
135
+ } = opts;
136
+
137
+ const workers = assignments.map(({ target, cli, subtask }) => ({
138
+ target,
139
+ cli,
140
+ subtask,
141
+ agentId: `${cli}-${target.split(".").pop()}`,
142
+ }));
143
+
144
+ if (lead?.target) {
145
+ const leadAgentId = `${lead.cli || "claude"}-${lead.target.split(".").pop()}`;
146
+ const leadPrompt = buildLeadPrompt(lead.task || "팀 작업 조율", {
147
+ agentId: leadAgentId,
148
+ hubUrl,
149
+ teammateMode,
150
+ workers: workers.map((w) => ({ agentId: w.agentId, cli: w.cli, subtask: w.subtask })),
151
+ });
152
+ injectPrompt(lead.target, leadPrompt);
153
+ await new Promise((r) => setTimeout(r, 100));
154
+ }
155
+
156
+ for (const worker of workers) {
157
+ const prompt = buildPrompt(worker.subtask, {
158
+ cli: worker.cli,
159
+ agentId: worker.agentId,
160
+ hubUrl,
161
+ sessionName,
162
+ });
163
+ injectPrompt(worker.target, prompt);
164
+ await new Promise((r) => setTimeout(r, 100));
165
+ }
166
+ }