triflux 3.3.0-dev.3 → 3.3.0-dev.6
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/hub/assign-callbacks.mjs +136 -0
- package/hub/bridge.mjs +289 -98
- package/hub/pipe.mjs +81 -0
- package/hub/server.mjs +159 -133
- package/hub/store.mjs +36 -2
- package/hub/team/cli-team-status.mjs +17 -3
- package/hub/team/native-supervisor.mjs +62 -22
- package/hub/team/native.mjs +266 -200
- package/hub/team/nativeProxy.mjs +173 -72
- package/hub/tools.mjs +6 -6
- package/hub/workers/delegator-mcp.mjs +285 -140
- package/package.json +60 -60
- package/scripts/completions/tfx.bash +47 -0
- package/scripts/completions/tfx.fish +44 -0
- package/scripts/completions/tfx.zsh +83 -0
- package/scripts/lib/mcp-filter.mjs +642 -0
- package/scripts/lib/mcp-server-catalog.mjs +118 -0
- package/scripts/mcp-check.mjs +126 -88
- package/scripts/test-tfx-route-no-claude-native.mjs +10 -2
- package/scripts/tfx-route.sh +504 -180
- package/skills/tfx-auto/SKILL.md +6 -1
package/hub/team/nativeProxy.mjs
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
// hub/team/nativeProxy.mjs
|
|
2
2
|
// Claude Native Teams 파일을 Hub tool/REST에서 안전하게 읽고 쓰기 위한 유틸.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
existsSync,
|
|
6
|
-
mkdirSync,
|
|
7
|
-
renameSync,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import {
|
|
12
|
+
open as openFile,
|
|
13
|
+
readdir,
|
|
14
|
+
readFile,
|
|
15
|
+
stat,
|
|
16
|
+
unlink as unlinkFile,
|
|
17
|
+
} from 'node:fs/promises';
|
|
15
18
|
import { basename, dirname, join } from 'node:path';
|
|
16
19
|
import { homedir } from 'node:os';
|
|
17
20
|
import { randomUUID } from 'node:crypto';
|
|
18
21
|
|
|
19
22
|
const TEAM_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
20
23
|
const CLAUDE_HOME = join(homedir(), '.claude');
|
|
21
|
-
const TEAMS_ROOT = join(CLAUDE_HOME, 'teams');
|
|
22
|
-
const TASKS_ROOT = join(CLAUDE_HOME, 'tasks');
|
|
24
|
+
const TEAMS_ROOT = join(CLAUDE_HOME, 'teams');
|
|
25
|
+
const TASKS_ROOT = join(CLAUDE_HOME, 'tasks');
|
|
26
|
+
const LOCK_STALE_MS = 30000;
|
|
23
27
|
|
|
24
28
|
// ── 인메모리 캐시 (디렉토리 mtime 기반 무효화) ──
|
|
25
29
|
const _dirCache = new Map(); // tasksDir → { mtimeMs, files: string[] }
|
|
@@ -66,29 +70,118 @@ function atomicWriteJson(path, value) {
|
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
async function sleepMs(ms) {
|
|
70
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function
|
|
74
|
-
let
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
73
|
+
async function sleepMs(ms) {
|
|
74
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readLockInfo(lockPath) {
|
|
78
|
+
let lockStat;
|
|
79
|
+
try {
|
|
80
|
+
lockStat = await stat(lockPath);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let parsed = null;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(await readFile(lockPath, 'utf8'));
|
|
88
|
+
} catch {}
|
|
89
|
+
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const createdAtMs = Number(
|
|
92
|
+
parsed?.created_at_ms
|
|
93
|
+
?? parsed?.timestamp_ms
|
|
94
|
+
?? parsed?.timestamp
|
|
95
|
+
?? lockStat.mtimeMs,
|
|
96
|
+
);
|
|
97
|
+
const pid = Number(parsed?.pid);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
token: typeof parsed?.token === 'string' ? parsed.token : null,
|
|
101
|
+
pid: Number.isInteger(pid) && pid > 0 ? pid : null,
|
|
102
|
+
created_at_ms: Number.isFinite(createdAtMs) ? createdAtMs : lockStat.mtimeMs,
|
|
103
|
+
mtime_ms: lockStat.mtimeMs,
|
|
104
|
+
age_ms: Math.max(0, now - (Number.isFinite(createdAtMs) ? createdAtMs : lockStat.mtimeMs)),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isPidAlive(pid) {
|
|
109
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
process.kill(pid, 0);
|
|
113
|
+
return true;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e?.code === 'EPERM') return true;
|
|
116
|
+
if (e?.code === 'ESRCH') return false;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function releaseFileLock(lockPath, token, handle) {
|
|
122
|
+
try { await handle?.close(); } catch {}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const current = await readLockInfo(lockPath);
|
|
126
|
+
if (!current || current.token === token) {
|
|
127
|
+
await unlinkFile(lockPath);
|
|
128
|
+
}
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function withFileLock(lockPath, fn, retries = 20, delayMs = 25, staleMs = LOCK_STALE_MS) {
|
|
133
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
134
|
+
const lockOwner = {
|
|
135
|
+
pid: process.pid,
|
|
136
|
+
token: randomUUID(),
|
|
137
|
+
created_at: new Date().toISOString(),
|
|
138
|
+
created_at_ms: Date.now(),
|
|
139
|
+
};
|
|
140
|
+
let handle = null;
|
|
141
|
+
let lastError = null;
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < retries; i += 1) {
|
|
144
|
+
try {
|
|
145
|
+
handle = await openFile(lockPath, 'wx');
|
|
146
|
+
try {
|
|
147
|
+
await handle.writeFile(`${JSON.stringify(lockOwner)}\n`, 'utf8');
|
|
148
|
+
} catch (writeError) {
|
|
149
|
+
await releaseFileLock(lockPath, lockOwner.token, handle);
|
|
150
|
+
throw writeError;
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
} catch (e) {
|
|
154
|
+
lastError = e;
|
|
155
|
+
if (e?.code !== 'EEXIST') throw e;
|
|
156
|
+
|
|
157
|
+
const current = await readLockInfo(lockPath);
|
|
158
|
+
const staleByAge = !current || current.age_ms > staleMs;
|
|
159
|
+
const staleByDeadPid = current?.pid != null && !isPidAlive(current.pid);
|
|
160
|
+
if (staleByAge || staleByDeadPid) {
|
|
161
|
+
try {
|
|
162
|
+
await unlinkFile(lockPath);
|
|
163
|
+
continue;
|
|
164
|
+
} catch (unlinkError) {
|
|
165
|
+
if (unlinkError?.code === 'ENOENT') continue;
|
|
166
|
+
lastError = unlinkError;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (i === retries - 1) throw e;
|
|
171
|
+
await sleepMs(delayMs);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!handle) {
|
|
176
|
+
throw lastError || new Error(`LOCK_NOT_ACQUIRED: ${lockPath}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
return await fn();
|
|
181
|
+
} finally {
|
|
182
|
+
await releaseFileLock(lockPath, lockOwner.token, handle);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
92
185
|
|
|
93
186
|
function getLeadSessionId(config) {
|
|
94
187
|
return config?.leadSessionId
|
|
@@ -130,8 +223,8 @@ export async function resolveTeamPaths(teamName) {
|
|
|
130
223
|
};
|
|
131
224
|
}
|
|
132
225
|
|
|
133
|
-
async function collectTaskFiles(tasksDir) {
|
|
134
|
-
if (!existsSync(tasksDir)) return [];
|
|
226
|
+
async function collectTaskFiles(tasksDir) {
|
|
227
|
+
if (!existsSync(tasksDir)) return [];
|
|
135
228
|
|
|
136
229
|
// 디렉토리 mtime 기반 캐시 — O(N) I/O를 반복 호출 시 O(1)로 축소
|
|
137
230
|
let dirMtime;
|
|
@@ -149,9 +242,30 @@ async function collectTaskFiles(tasksDir) {
|
|
|
149
242
|
.filter((name) => name !== '.highwatermark')
|
|
150
243
|
.map((name) => join(tasksDir, name));
|
|
151
244
|
|
|
152
|
-
_dirCache.set(tasksDir, { mtimeMs: dirMtime, files });
|
|
153
|
-
return files;
|
|
154
|
-
}
|
|
245
|
+
_dirCache.set(tasksDir, { mtimeMs: dirMtime, files });
|
|
246
|
+
return files;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function readTaskFileCached(file) {
|
|
250
|
+
let fileMtime;
|
|
251
|
+
try {
|
|
252
|
+
fileMtime = (await stat(file)).mtimeMs;
|
|
253
|
+
} catch {
|
|
254
|
+
return { file, mtimeMs: null, json: null };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const contentCached = _taskContentCache.get(file);
|
|
258
|
+
if (contentCached && contentCached.mtimeMs === fileMtime) {
|
|
259
|
+
return { file, mtimeMs: fileMtime, json: contentCached.data };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const json = await readJsonSafe(file);
|
|
263
|
+
if (json && isObject(json)) {
|
|
264
|
+
_taskContentCache.set(file, { mtimeMs: fileMtime, data: json });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { file, mtimeMs: fileMtime, json };
|
|
268
|
+
}
|
|
155
269
|
|
|
156
270
|
async function locateTaskFile(tasksDir, taskId) {
|
|
157
271
|
const direct = join(tasksDir, `${taskId}.json`);
|
|
@@ -244,30 +358,17 @@ export async function teamTaskList(args = {}) {
|
|
|
244
358
|
return err('TASKS_DIR_NOT_FOUND', `task 디렉토리를 찾지 못했습니다: ${team_name}`);
|
|
245
359
|
}
|
|
246
360
|
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
let json;
|
|
259
|
-
if (contentCached && contentCached.mtimeMs === fileMtime) {
|
|
260
|
-
json = contentCached.data;
|
|
261
|
-
} else {
|
|
262
|
-
json = await readJsonSafe(file);
|
|
263
|
-
if (json && isObject(json)) {
|
|
264
|
-
_taskContentCache.set(file, { mtimeMs: fileMtime, data: json });
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (!json || !isObject(json)) {
|
|
269
|
-
parseWarnings += 1;
|
|
270
|
-
continue;
|
|
361
|
+
const statusSet = new Set((statuses || []).map((s) => String(s)));
|
|
362
|
+
const maxCount = Math.max(1, Math.min(Number(limit) || 200, 1000));
|
|
363
|
+
let parseWarnings = 0;
|
|
364
|
+
const files = await collectTaskFiles(paths.tasks_dir);
|
|
365
|
+
const records = await Promise.all(files.map((file) => readTaskFileCached(file)));
|
|
366
|
+
|
|
367
|
+
const tasks = [];
|
|
368
|
+
for (const { file, mtimeMs: fileMtime, json } of records) {
|
|
369
|
+
if (!json || !isObject(json)) {
|
|
370
|
+
parseWarnings += 1;
|
|
371
|
+
continue;
|
|
271
372
|
}
|
|
272
373
|
|
|
273
374
|
if (!include_internal && json?.metadata?._internal === true) continue;
|
|
@@ -440,13 +541,13 @@ export async function teamTaskUpdate(args = {}) {
|
|
|
440
541
|
_invalidateCache(dirname(taskFile));
|
|
441
542
|
// 콘텐츠 캐시 무효화
|
|
442
543
|
_taskContentCache.delete(taskFile);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
let afterMtime = beforeMtime;
|
|
446
|
-
try { afterMtime =
|
|
447
|
-
|
|
448
|
-
return {
|
|
449
|
-
ok: true,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let afterMtime = beforeMtime;
|
|
547
|
+
try { afterMtime = (await stat(taskFile)).mtimeMs; } catch {}
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
ok: true,
|
|
450
551
|
data: {
|
|
451
552
|
claimed,
|
|
452
553
|
updated,
|
package/hub/tools.mjs
CHANGED
|
@@ -338,9 +338,9 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
338
338
|
},
|
|
339
339
|
|
|
340
340
|
// ── 13. team_task_list ──
|
|
341
|
-
{
|
|
342
|
-
name: 'team_task_list',
|
|
343
|
-
description: 'Claude Native Teams task 목록을 owner/status 조건으로
|
|
341
|
+
{
|
|
342
|
+
name: 'team_task_list',
|
|
343
|
+
description: 'Claude Native Teams task 목록을 owner/status 조건으로 조회합니다. 실패 판정은 completed + metadata.result도 함께 확인해야 합니다',
|
|
344
344
|
inputSchema: {
|
|
345
345
|
type: 'object',
|
|
346
346
|
required: ['team_name'],
|
|
@@ -362,9 +362,9 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
362
362
|
},
|
|
363
363
|
|
|
364
364
|
// ── 14. team_task_update ──
|
|
365
|
-
{
|
|
366
|
-
name: 'team_task_update',
|
|
367
|
-
description: 'Claude Native Teams task를 claim/update
|
|
365
|
+
{
|
|
366
|
+
name: 'team_task_update',
|
|
367
|
+
description: 'Claude Native Teams task를 claim/update 합니다. status: "failed" 입력은 completed + metadata.result="failed"로 정규화됩니다',
|
|
368
368
|
inputSchema: {
|
|
369
369
|
type: 'object',
|
|
370
370
|
required: ['team_name', 'task_id'],
|