triflux 3.2.0-dev.1 → 3.2.0-dev.11
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 +90 -31
- package/hub/team/pane.mjs +149 -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
|
@@ -0,0 +1,534 @@
|
|
|
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
|
+
// ── 인메모리 캐시 (디렉토리 mtime 기반 무효화) ──
|
|
26
|
+
const _dirCache = new Map(); // tasksDir → { mtimeMs, files: string[] }
|
|
27
|
+
const _taskIdIndex = new Map(); // taskId → filePath
|
|
28
|
+
|
|
29
|
+
function _invalidateCache(tasksDir) {
|
|
30
|
+
_dirCache.delete(tasksDir);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function err(code, message, extra = {}) {
|
|
34
|
+
return { ok: false, error: { code, message, ...extra } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validateTeamName(teamName) {
|
|
38
|
+
if (!TEAM_NAME_RE.test(String(teamName || ''))) {
|
|
39
|
+
throw new Error('INVALID_TEAM_NAME');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readJsonSafe(path) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function atomicWriteJson(path, value) {
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
54
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
55
|
+
try {
|
|
56
|
+
renameSync(tmp, path);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// Windows NTFS: 대상 파일 존재 시 rename 실패 가능 → 삭제 후 재시도
|
|
59
|
+
if (process.platform === 'win32' && (e.code === 'EPERM' || e.code === 'EEXIST')) {
|
|
60
|
+
try { unlinkSync(path); } catch {}
|
|
61
|
+
renameSync(tmp, path);
|
|
62
|
+
} else {
|
|
63
|
+
try { unlinkSync(tmp); } catch {}
|
|
64
|
+
throw e;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sleepMs(ms) {
|
|
70
|
+
// busy-wait를 피하고 Atomics.wait로 동기 대기 (CPU 점유 최소화)
|
|
71
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function withFileLock(lockPath, fn, retries = 20, delayMs = 25) {
|
|
75
|
+
let fd = null;
|
|
76
|
+
for (let i = 0; i < retries; i += 1) {
|
|
77
|
+
try {
|
|
78
|
+
fd = openSync(lockPath, 'wx');
|
|
79
|
+
break;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (e?.code !== 'EEXIST' || i === retries - 1) throw e;
|
|
82
|
+
sleepMs(delayMs);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
return fn();
|
|
88
|
+
} finally {
|
|
89
|
+
try { if (fd != null) closeSync(fd); } catch {}
|
|
90
|
+
try { unlinkSync(lockPath); } catch {}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getLeadSessionId(config) {
|
|
95
|
+
return config?.leadSessionId
|
|
96
|
+
|| config?.lead_session_id
|
|
97
|
+
|| config?.lead?.lead_session_id
|
|
98
|
+
|| config?.lead?.sessionId
|
|
99
|
+
|| null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveTeamPaths(teamName) {
|
|
103
|
+
validateTeamName(teamName);
|
|
104
|
+
|
|
105
|
+
const teamDir = join(TEAMS_ROOT, teamName);
|
|
106
|
+
const configPath = join(teamDir, 'config.json');
|
|
107
|
+
const inboxesDir = join(teamDir, 'inboxes');
|
|
108
|
+
const config = readJsonSafe(configPath);
|
|
109
|
+
const leadSessionId = getLeadSessionId(config);
|
|
110
|
+
|
|
111
|
+
const byTeam = join(TASKS_ROOT, teamName);
|
|
112
|
+
const byLeadSession = leadSessionId ? join(TASKS_ROOT, leadSessionId) : null;
|
|
113
|
+
|
|
114
|
+
let tasksDir = byTeam;
|
|
115
|
+
let tasksDirResolution = 'not_found';
|
|
116
|
+
if (existsSync(byTeam)) {
|
|
117
|
+
tasksDirResolution = 'team_name';
|
|
118
|
+
} else if (byLeadSession && existsSync(byLeadSession)) {
|
|
119
|
+
tasksDir = byLeadSession;
|
|
120
|
+
tasksDirResolution = 'lead_session_id';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
team_dir: teamDir,
|
|
125
|
+
config_path: configPath,
|
|
126
|
+
inboxes_dir: inboxesDir,
|
|
127
|
+
tasks_dir: tasksDir,
|
|
128
|
+
tasks_dir_resolution: tasksDirResolution,
|
|
129
|
+
lead_session_id: leadSessionId,
|
|
130
|
+
config,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function collectTaskFiles(tasksDir) {
|
|
135
|
+
if (!existsSync(tasksDir)) return [];
|
|
136
|
+
|
|
137
|
+
// 디렉토리 mtime 기반 캐시 — O(N) I/O를 반복 호출 시 O(1)로 축소
|
|
138
|
+
let dirMtime;
|
|
139
|
+
try { dirMtime = statSync(tasksDir).mtimeMs; } catch { return []; }
|
|
140
|
+
|
|
141
|
+
const cached = _dirCache.get(tasksDir);
|
|
142
|
+
if (cached && cached.mtimeMs === dirMtime) {
|
|
143
|
+
return cached.files;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const files = readdirSync(tasksDir)
|
|
147
|
+
.filter((name) => name.endsWith('.json'))
|
|
148
|
+
.filter((name) => !name.endsWith('.lock'))
|
|
149
|
+
.filter((name) => name !== '.highwatermark')
|
|
150
|
+
.map((name) => join(tasksDir, name));
|
|
151
|
+
|
|
152
|
+
_dirCache.set(tasksDir, { mtimeMs: dirMtime, files });
|
|
153
|
+
return files;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function locateTaskFile(tasksDir, taskId) {
|
|
157
|
+
const direct = join(tasksDir, `${taskId}.json`);
|
|
158
|
+
if (existsSync(direct)) return direct;
|
|
159
|
+
|
|
160
|
+
// ID→파일 인덱스 캐시
|
|
161
|
+
const indexed = _taskIdIndex.get(taskId);
|
|
162
|
+
if (indexed && existsSync(indexed)) return indexed;
|
|
163
|
+
|
|
164
|
+
// 캐시된 collectTaskFiles로 풀 스캔
|
|
165
|
+
const files = collectTaskFiles(tasksDir);
|
|
166
|
+
for (const file of files) {
|
|
167
|
+
if (basename(file, '.json') === taskId) {
|
|
168
|
+
_taskIdIndex.set(taskId, file);
|
|
169
|
+
return file;
|
|
170
|
+
}
|
|
171
|
+
const json = readJsonSafe(file);
|
|
172
|
+
if (json && String(json.id || '') === taskId) {
|
|
173
|
+
_taskIdIndex.set(taskId, file);
|
|
174
|
+
return file;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isObject(v) {
|
|
181
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function teamInfo(args = {}) {
|
|
185
|
+
const { team_name, include_members = true, include_paths = true } = args;
|
|
186
|
+
try {
|
|
187
|
+
validateTeamName(team_name);
|
|
188
|
+
} catch {
|
|
189
|
+
return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const paths = resolveTeamPaths(team_name);
|
|
193
|
+
if (!existsSync(paths.team_dir)) {
|
|
194
|
+
return err('TEAM_NOT_FOUND', `팀 디렉토리가 없습니다: ${paths.team_dir}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const members = Array.isArray(paths.config?.members) ? paths.config.members : [];
|
|
198
|
+
const leadAgentId = paths.config?.leadAgentId
|
|
199
|
+
|| paths.config?.lead_agent_id
|
|
200
|
+
|| members[0]?.agentId
|
|
201
|
+
|| null;
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
data: {
|
|
206
|
+
team: {
|
|
207
|
+
team_name,
|
|
208
|
+
description: paths.config?.description || null,
|
|
209
|
+
},
|
|
210
|
+
lead: {
|
|
211
|
+
lead_agent_id: leadAgentId,
|
|
212
|
+
lead_session_id: paths.lead_session_id,
|
|
213
|
+
},
|
|
214
|
+
...(include_members ? { members } : {}),
|
|
215
|
+
...(include_paths ? {
|
|
216
|
+
paths: {
|
|
217
|
+
config_path: paths.config_path,
|
|
218
|
+
tasks_dir: paths.tasks_dir,
|
|
219
|
+
inboxes_dir: paths.inboxes_dir,
|
|
220
|
+
tasks_dir_resolution: paths.tasks_dir_resolution,
|
|
221
|
+
},
|
|
222
|
+
} : {}),
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function teamTaskList(args = {}) {
|
|
228
|
+
const {
|
|
229
|
+
team_name,
|
|
230
|
+
owner,
|
|
231
|
+
statuses = [],
|
|
232
|
+
include_internal = false,
|
|
233
|
+
limit = 200,
|
|
234
|
+
} = args;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
validateTeamName(team_name);
|
|
238
|
+
} catch {
|
|
239
|
+
return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const paths = resolveTeamPaths(team_name);
|
|
243
|
+
if (paths.tasks_dir_resolution === 'not_found') {
|
|
244
|
+
return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const statusSet = new Set((statuses || []).map((s) => String(s)));
|
|
248
|
+
const maxCount = Math.max(1, Math.min(Number(limit) || 200, 1000));
|
|
249
|
+
let parseWarnings = 0;
|
|
250
|
+
|
|
251
|
+
const tasks = [];
|
|
252
|
+
for (const file of collectTaskFiles(paths.tasks_dir)) {
|
|
253
|
+
const json = readJsonSafe(file);
|
|
254
|
+
if (!json || !isObject(json)) {
|
|
255
|
+
parseWarnings += 1;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!include_internal && json?.metadata?._internal === true) continue;
|
|
260
|
+
if (owner && String(json.owner || '') !== String(owner)) continue;
|
|
261
|
+
if (statusSet.size > 0 && !statusSet.has(String(json.status || ''))) continue;
|
|
262
|
+
|
|
263
|
+
let mtime = Date.now();
|
|
264
|
+
try { mtime = statSync(file).mtimeMs; } catch {}
|
|
265
|
+
|
|
266
|
+
tasks.push({
|
|
267
|
+
...json,
|
|
268
|
+
task_file: file,
|
|
269
|
+
mtime_ms: mtime,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
tasks.sort((a, b) => Number(b.mtime_ms || 0) - Number(a.mtime_ms || 0));
|
|
274
|
+
const sliced = tasks.slice(0, maxCount);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
data: {
|
|
279
|
+
tasks: sliced,
|
|
280
|
+
count: sliced.length,
|
|
281
|
+
parse_warnings: parseWarnings,
|
|
282
|
+
tasks_dir: paths.tasks_dir,
|
|
283
|
+
tasks_dir_resolution: paths.tasks_dir_resolution,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// status 화이트리스트 (Claude Code API 호환)
|
|
289
|
+
const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
|
290
|
+
|
|
291
|
+
export function teamTaskUpdate(args = {}) {
|
|
292
|
+
// "failed" → "completed" + metadata.result 자동 매핑
|
|
293
|
+
if (String(args.status || '') === 'failed') {
|
|
294
|
+
args = {
|
|
295
|
+
...args,
|
|
296
|
+
status: 'completed',
|
|
297
|
+
metadata_patch: { ...(args.metadata_patch || {}), result: 'failed' },
|
|
298
|
+
};
|
|
299
|
+
} else if (args.status != null && !VALID_STATUSES.has(String(args.status))) {
|
|
300
|
+
return err('INVALID_STATUS', `유효하지 않은 status: ${args.status}. 허용: ${[...VALID_STATUSES].join(', ')}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const {
|
|
304
|
+
team_name,
|
|
305
|
+
task_id,
|
|
306
|
+
claim = false,
|
|
307
|
+
owner,
|
|
308
|
+
status,
|
|
309
|
+
subject,
|
|
310
|
+
description,
|
|
311
|
+
activeForm,
|
|
312
|
+
add_blocks = [],
|
|
313
|
+
add_blocked_by = [],
|
|
314
|
+
metadata_patch,
|
|
315
|
+
if_match_mtime_ms,
|
|
316
|
+
actor,
|
|
317
|
+
} = args;
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
validateTeamName(team_name);
|
|
321
|
+
} catch {
|
|
322
|
+
return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!String(task_id || '').trim()) {
|
|
326
|
+
return err('INVALID_TASK_ID', 'task_id가 필요합니다');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const paths = resolveTeamPaths(team_name);
|
|
330
|
+
if (paths.tasks_dir_resolution === 'not_found') {
|
|
331
|
+
return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const taskFile = locateTaskFile(paths.tasks_dir, String(task_id));
|
|
335
|
+
if (!taskFile) {
|
|
336
|
+
return err('TASK_NOT_FOUND', `task를 찾지 못했습니다: ${task_id}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const lockFile = `${taskFile}.lock`;
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
return withFileLock(lockFile, () => {
|
|
343
|
+
const before = readJsonSafe(taskFile);
|
|
344
|
+
if (!before || !isObject(before)) {
|
|
345
|
+
return err('INVALID_TASK_FILE', `task 파일 파싱 실패: ${taskFile}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let beforeMtime = Date.now();
|
|
349
|
+
try { beforeMtime = statSync(taskFile).mtimeMs; } catch {}
|
|
350
|
+
|
|
351
|
+
if (if_match_mtime_ms != null && Number(if_match_mtime_ms) !== Number(beforeMtime)) {
|
|
352
|
+
return err('MTIME_CONFLICT', 'if_match_mtime_ms가 일치하지 않습니다', {
|
|
353
|
+
task_file: taskFile,
|
|
354
|
+
mtime_ms: beforeMtime,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const after = JSON.parse(JSON.stringify(before));
|
|
359
|
+
let claimed = false;
|
|
360
|
+
let updated = false;
|
|
361
|
+
|
|
362
|
+
if (claim) {
|
|
363
|
+
const requestedOwner = String(owner || actor || '');
|
|
364
|
+
const ownerNow = String(before.owner || '');
|
|
365
|
+
const ownerCompatible = ownerNow === '' || requestedOwner === '' || ownerNow === requestedOwner;
|
|
366
|
+
const statusPending = String(before.status || '') === 'pending';
|
|
367
|
+
|
|
368
|
+
if (!statusPending || !ownerCompatible) {
|
|
369
|
+
return err('CLAIM_CONFLICT', 'task claim 충돌', {
|
|
370
|
+
task_before: before,
|
|
371
|
+
task_file: taskFile,
|
|
372
|
+
mtime_ms: beforeMtime,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (requestedOwner) after.owner = requestedOwner;
|
|
377
|
+
after.status = status || 'in_progress';
|
|
378
|
+
claimed = true;
|
|
379
|
+
updated = true;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (owner != null && String(after.owner || '') !== String(owner)) {
|
|
383
|
+
after.owner = owner;
|
|
384
|
+
updated = true;
|
|
385
|
+
}
|
|
386
|
+
if (status != null && String(after.status || '') !== String(status)) {
|
|
387
|
+
after.status = status;
|
|
388
|
+
updated = true;
|
|
389
|
+
}
|
|
390
|
+
if (subject != null && String(after.subject || '') !== String(subject)) {
|
|
391
|
+
after.subject = subject;
|
|
392
|
+
updated = true;
|
|
393
|
+
}
|
|
394
|
+
if (description != null && String(after.description || '') !== String(description)) {
|
|
395
|
+
after.description = description;
|
|
396
|
+
updated = true;
|
|
397
|
+
}
|
|
398
|
+
if (activeForm != null && String(after.activeForm || '') !== String(activeForm)) {
|
|
399
|
+
after.activeForm = activeForm;
|
|
400
|
+
updated = true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (Array.isArray(add_blocks) && add_blocks.length > 0) {
|
|
404
|
+
const current = Array.isArray(after.blocks) ? [...after.blocks] : [];
|
|
405
|
+
for (const item of add_blocks) {
|
|
406
|
+
if (!current.includes(item)) current.push(item);
|
|
407
|
+
}
|
|
408
|
+
after.blocks = current;
|
|
409
|
+
updated = true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (Array.isArray(add_blocked_by) && add_blocked_by.length > 0) {
|
|
413
|
+
const current = Array.isArray(after.blockedBy) ? [...after.blockedBy] : [];
|
|
414
|
+
for (const item of add_blocked_by) {
|
|
415
|
+
if (!current.includes(item)) current.push(item);
|
|
416
|
+
}
|
|
417
|
+
after.blockedBy = current;
|
|
418
|
+
updated = true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (isObject(metadata_patch)) {
|
|
422
|
+
const base = isObject(after.metadata) ? after.metadata : {};
|
|
423
|
+
after.metadata = { ...base, ...metadata_patch };
|
|
424
|
+
updated = true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (updated) {
|
|
428
|
+
atomicWriteJson(taskFile, after);
|
|
429
|
+
_invalidateCache(dirname(taskFile));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let afterMtime = beforeMtime;
|
|
433
|
+
try { afterMtime = statSync(taskFile).mtimeMs; } catch {}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
ok: true,
|
|
437
|
+
data: {
|
|
438
|
+
claimed,
|
|
439
|
+
updated,
|
|
440
|
+
task_before: before,
|
|
441
|
+
task_after: updated ? after : before,
|
|
442
|
+
task_file: taskFile,
|
|
443
|
+
mtime_ms: afterMtime,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
});
|
|
447
|
+
} catch (e) {
|
|
448
|
+
return err('TASK_UPDATE_FAILED', e.message);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function sanitizeRecipientName(v) {
|
|
453
|
+
return String(v || 'team-lead').replace(/[\\/:*?"<>|]/g, '_');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function teamSendMessage(args = {}) {
|
|
457
|
+
const {
|
|
458
|
+
team_name,
|
|
459
|
+
from,
|
|
460
|
+
to = 'team-lead',
|
|
461
|
+
text,
|
|
462
|
+
summary,
|
|
463
|
+
color = 'blue',
|
|
464
|
+
} = args;
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
validateTeamName(team_name);
|
|
468
|
+
} catch {
|
|
469
|
+
return err('INVALID_TEAM_NAME', 'team_name 형식이 올바르지 않습니다');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!String(from || '').trim()) return err('INVALID_FROM', 'from이 필요합니다');
|
|
473
|
+
if (!String(text || '').trim()) return err('INVALID_TEXT', 'text가 필요합니다');
|
|
474
|
+
|
|
475
|
+
const paths = resolveTeamPaths(team_name);
|
|
476
|
+
if (!existsSync(paths.team_dir)) {
|
|
477
|
+
return err('TEAM_NOT_FOUND', `팀 디렉토리가 없습니다: ${paths.team_dir}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const recipient = sanitizeRecipientName(to);
|
|
481
|
+
const inboxFile = join(paths.inboxes_dir, `${recipient}.json`);
|
|
482
|
+
const lockFile = `${inboxFile}.lock`;
|
|
483
|
+
let message;
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
const unreadCount = withFileLock(lockFile, () => {
|
|
487
|
+
const queue = readJsonSafe(inboxFile);
|
|
488
|
+
const list = Array.isArray(queue) ? queue : [];
|
|
489
|
+
|
|
490
|
+
message = {
|
|
491
|
+
id: randomUUID(),
|
|
492
|
+
from: String(from),
|
|
493
|
+
text: String(text),
|
|
494
|
+
...(summary ? { summary: String(summary) } : {}),
|
|
495
|
+
timestamp: new Date().toISOString(),
|
|
496
|
+
color: String(color || 'blue'),
|
|
497
|
+
read: false,
|
|
498
|
+
};
|
|
499
|
+
list.push(message);
|
|
500
|
+
|
|
501
|
+
// inbox 정리: 최대 200개 유지, read + 1시간 경과 메시지 제거
|
|
502
|
+
const MAX_INBOX = 200;
|
|
503
|
+
if (list.length > MAX_INBOX) {
|
|
504
|
+
const ONE_HOUR_MS = 3600000;
|
|
505
|
+
const cutoff = Date.now() - ONE_HOUR_MS;
|
|
506
|
+
const pruned = list.filter((m) =>
|
|
507
|
+
m?.read !== true || !m?.timestamp || new Date(m.timestamp).getTime() > cutoff
|
|
508
|
+
);
|
|
509
|
+
list.length = 0;
|
|
510
|
+
list.push(...pruned);
|
|
511
|
+
if (list.length > MAX_INBOX) {
|
|
512
|
+
list.splice(0, list.length - MAX_INBOX);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
atomicWriteJson(inboxFile, list);
|
|
517
|
+
|
|
518
|
+
return list.filter((m) => m?.read !== true).length;
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
ok: true,
|
|
523
|
+
data: {
|
|
524
|
+
message_id: message.id,
|
|
525
|
+
recipient,
|
|
526
|
+
inbox_file: inboxFile,
|
|
527
|
+
queued_at: message.timestamp,
|
|
528
|
+
unread_count: unreadCount,
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
} catch (e) {
|
|
532
|
+
return err('SEND_MESSAGE_FAILED', e.message);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
@@ -40,45 +40,76 @@ export function decomposeTask(taskDescription, agentCount) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
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
|
+
const bridgePath = "node hub/bridge.mjs";
|
|
62
|
+
|
|
63
|
+
return `리드 에이전트: ${agentId}
|
|
64
|
+
|
|
65
|
+
목표: ${taskDescription}
|
|
66
|
+
모드: ${teammateMode}
|
|
67
|
+
|
|
68
|
+
워커:
|
|
69
|
+
${roster}
|
|
70
|
+
|
|
71
|
+
규칙:
|
|
72
|
+
- 가능한 짧고 핵심만 지시/요약(토큰 절약)
|
|
73
|
+
- 워커 제어:
|
|
74
|
+
${bridgePath} result --agent ${agentId} --topic lead.control
|
|
75
|
+
- 워커 결과 수집:
|
|
76
|
+
${bridgePath} context --agent ${agentId} --max 20
|
|
77
|
+
- 최종 결과는 topic="task.result"를 모아 통합
|
|
78
|
+
|
|
79
|
+
워커 ID: ${workerIds || "(없음)"}
|
|
80
|
+
지금 즉시 워커를 배정하고 병렬 진행을 관리하라.`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 워커 초기 프롬프트 생성
|
|
44
85
|
* @param {string} subtask — 이 에이전트의 서브태스크
|
|
45
86
|
* @param {object} config
|
|
46
87
|
* @param {string} config.cli — codex/gemini/claude
|
|
47
88
|
* @param {string} config.agentId — 에이전트 식별자
|
|
48
89
|
* @param {string} config.hubUrl — Hub URL
|
|
49
|
-
* @param {string} config.sessionName — tmux 세션 이름
|
|
50
90
|
* @returns {string}
|
|
51
91
|
*/
|
|
52
92
|
export function buildPrompt(subtask, config) {
|
|
53
93
|
const { cli, agentId, hubUrl } = config;
|
|
54
94
|
|
|
55
|
-
|
|
56
|
-
const hubInstructions = `
|
|
57
|
-
[Hub 메시지 도구]
|
|
58
|
-
tfx-hub MCP 서버(${hubUrl})가 연결되어 있다면 아래 도구를 사용할 수 있다:
|
|
59
|
-
- register: 에이전트 등록 (agent_id: "${agentId}", cli: "${cli}")
|
|
60
|
-
- publish: 결과 발행 (topic: "task.result")
|
|
61
|
-
- poll_messages: 다른 에이전트 메시지 수신
|
|
62
|
-
- ask: 다른 에이전트에게 질문
|
|
95
|
+
const hubBase = hubUrl.replace("/mcp", "");
|
|
63
96
|
|
|
64
|
-
|
|
65
|
-
curl -s -X POST ${hubUrl.replace("/mcp", "")}/bridge/register -H 'Content-Type: application/json' -d '{"agent_id":"${agentId}","cli":"${cli}","timeout_sec":600}'
|
|
66
|
-
curl -s -X POST ${hubUrl.replace("/mcp", "")}/bridge/result -H 'Content-Type: application/json' -d '{"agent_id":"${agentId}","topic":"task.result","payload":{"summary":"결과 요약"}}'
|
|
67
|
-
`.trim();
|
|
97
|
+
const bridgePath = "node hub/bridge.mjs";
|
|
68
98
|
|
|
69
|
-
return
|
|
99
|
+
return `워커: ${agentId} (${cli})
|
|
100
|
+
작업: ${subtask}
|
|
70
101
|
|
|
71
|
-
|
|
72
|
-
|
|
102
|
+
필수 규칙:
|
|
103
|
+
1) 간결하게 작업(불필요한 장문 설명 금지)
|
|
104
|
+
2) 시작 즉시 등록:
|
|
105
|
+
${bridgePath} register --agent ${agentId} --cli ${cli} --topics lead.control,task.result
|
|
106
|
+
3) 주기적으로 수신함 확인:
|
|
107
|
+
${bridgePath} context --agent ${agentId} --max 10
|
|
108
|
+
4) lead.control 수신 시 즉시 반응 (interrupt/stop/pause/resume)
|
|
109
|
+
5) 완료 시 결과 발행:
|
|
110
|
+
${bridgePath} result --agent ${agentId} --topic task.result --file <출력파일>
|
|
73
111
|
|
|
74
|
-
|
|
75
|
-
- 작업 완료 후 반드시 결과를 Hub에 발행하라
|
|
76
|
-
- 에이전트 ID: ${agentId}
|
|
77
|
-
- 다른 에이전트 결과가 필요하면 poll_messages로 확인
|
|
78
|
-
|
|
79
|
-
${hubInstructions}
|
|
80
|
-
|
|
81
|
-
작업을 시작하라.`;
|
|
112
|
+
지금 작업을 시작하라.`;
|
|
82
113
|
}
|
|
83
114
|
|
|
84
115
|
/**
|
|
@@ -87,16 +118,44 @@ ${hubInstructions}
|
|
|
87
118
|
* @param {Array<{target: string, cli: string, subtask: string}>} assignments
|
|
88
119
|
* @param {object} opts
|
|
89
120
|
* @param {string} opts.hubUrl — Hub URL
|
|
121
|
+
* @param {{target:string, cli:string, task:string}|null} opts.lead
|
|
122
|
+
* @param {string} opts.teammateMode
|
|
90
123
|
* @returns {Promise<void>}
|
|
91
124
|
*/
|
|
92
125
|
export async function orchestrate(sessionName, assignments, opts = {}) {
|
|
93
|
-
const {
|
|
126
|
+
const {
|
|
127
|
+
hubUrl = "http://127.0.0.1:27888/mcp",
|
|
128
|
+
lead = null,
|
|
129
|
+
teammateMode = "tmux",
|
|
130
|
+
} = opts;
|
|
131
|
+
|
|
132
|
+
const workers = assignments.map(({ target, cli, subtask }) => ({
|
|
133
|
+
target,
|
|
134
|
+
cli,
|
|
135
|
+
subtask,
|
|
136
|
+
agentId: `${cli}-${target.split(".").pop()}`,
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
if (lead?.target) {
|
|
140
|
+
const leadAgentId = `${lead.cli || "claude"}-${lead.target.split(".").pop()}`;
|
|
141
|
+
const leadPrompt = buildLeadPrompt(lead.task || "팀 작업 조율", {
|
|
142
|
+
agentId: leadAgentId,
|
|
143
|
+
hubUrl,
|
|
144
|
+
teammateMode,
|
|
145
|
+
workers: workers.map((w) => ({ agentId: w.agentId, cli: w.cli, subtask: w.subtask })),
|
|
146
|
+
});
|
|
147
|
+
injectPrompt(lead.target, leadPrompt, { useFileRef: true });
|
|
148
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
149
|
+
}
|
|
94
150
|
|
|
95
|
-
for (const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
151
|
+
for (const worker of workers) {
|
|
152
|
+
const prompt = buildPrompt(worker.subtask, {
|
|
153
|
+
cli: worker.cli,
|
|
154
|
+
agentId: worker.agentId,
|
|
155
|
+
hubUrl,
|
|
156
|
+
sessionName,
|
|
157
|
+
});
|
|
158
|
+
injectPrompt(worker.target, prompt, { useFileRef: true });
|
|
100
159
|
await new Promise((r) => setTimeout(r, 100));
|
|
101
160
|
}
|
|
102
161
|
}
|