@triflux/core 10.0.0-alpha.1
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/hooks/agent-route-guard.mjs +109 -0
- package/hooks/cross-review-tracker.mjs +122 -0
- package/hooks/error-context.mjs +148 -0
- package/hooks/hook-manager.mjs +352 -0
- package/hooks/hook-orchestrator.mjs +312 -0
- package/hooks/hook-registry.json +213 -0
- package/hooks/hooks.json +89 -0
- package/hooks/keyword-rules.json +581 -0
- package/hooks/lib/resolve-root.mjs +59 -0
- package/hooks/mcp-config-watcher.mjs +85 -0
- package/hooks/pipeline-stop.mjs +76 -0
- package/hooks/safety-guard.mjs +106 -0
- package/hooks/subagent-verifier.mjs +80 -0
- package/hub/assign-callbacks.mjs +133 -0
- package/hub/bridge.mjs +799 -0
- package/hub/cli-adapter-base.mjs +192 -0
- package/hub/codex-adapter.mjs +190 -0
- package/hub/codex-compat.mjs +78 -0
- package/hub/codex-preflight.mjs +147 -0
- package/hub/delegator/contracts.mjs +37 -0
- package/hub/delegator/index.mjs +14 -0
- package/hub/delegator/schema/delegator-tools.schema.json +250 -0
- package/hub/delegator/service.mjs +307 -0
- package/hub/delegator/tool-definitions.mjs +35 -0
- package/hub/fullcycle.mjs +96 -0
- package/hub/gemini-adapter.mjs +179 -0
- package/hub/hitl.mjs +143 -0
- package/hub/intent.mjs +193 -0
- package/hub/lib/process-utils.mjs +361 -0
- package/hub/middleware/request-logger.mjs +81 -0
- package/hub/paths.mjs +30 -0
- package/hub/pipeline/gates/confidence.mjs +56 -0
- package/hub/pipeline/gates/consensus.mjs +94 -0
- package/hub/pipeline/gates/index.mjs +5 -0
- package/hub/pipeline/gates/selfcheck.mjs +82 -0
- package/hub/pipeline/index.mjs +318 -0
- package/hub/pipeline/state.mjs +191 -0
- package/hub/pipeline/transitions.mjs +124 -0
- package/hub/platform.mjs +225 -0
- package/hub/quality/deslop.mjs +253 -0
- package/hub/reflexion.mjs +372 -0
- package/hub/research.mjs +146 -0
- package/hub/router.mjs +791 -0
- package/hub/routing/complexity.mjs +166 -0
- package/hub/routing/index.mjs +117 -0
- package/hub/routing/q-learning.mjs +336 -0
- package/hub/session-fingerprint.mjs +352 -0
- package/hub/state.mjs +245 -0
- package/hub/team-bridge.mjs +25 -0
- package/hub/token-mode.mjs +224 -0
- package/hub/workers/worker-utils.mjs +104 -0
- package/hud/colors.mjs +88 -0
- package/hud/constants.mjs +81 -0
- package/hud/hud-qos-status.mjs +206 -0
- package/hud/providers/claude.mjs +309 -0
- package/hud/providers/codex.mjs +151 -0
- package/hud/providers/gemini.mjs +320 -0
- package/hud/renderers.mjs +424 -0
- package/hud/terminal.mjs +140 -0
- package/hud/utils.mjs +287 -0
- package/package.json +31 -0
- package/scripts/lib/claudemd-manager.mjs +325 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +51 -0
- package/scripts/lib/env-probe.mjs +241 -0
- package/scripts/lib/gemini-profiles.mjs +85 -0
- package/scripts/lib/hook-utils.mjs +14 -0
- package/scripts/lib/keyword-rules.mjs +166 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +739 -0
- package/scripts/lib/mcp-guard-engine.mjs +940 -0
- package/scripts/lib/mcp-manifest.mjs +79 -0
- package/scripts/lib/mcp-server-catalog.mjs +118 -0
- package/scripts/lib/psmux-info.mjs +119 -0
- package/scripts/lib/remote-spawn-transfer.mjs +196 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { normalizePath } from './platform.mjs';
|
|
4
|
+
import { withRetry } from './workers/worker-utils.mjs';
|
|
5
|
+
|
|
6
|
+
const ADAPTIVE_FINGERPRINT_VERSION = 1;
|
|
7
|
+
const DEFAULT_SCOPE = 'default';
|
|
8
|
+
const META_PREFIX = 'adaptive_fingerprint:';
|
|
9
|
+
const DEFAULT_RETRY_OPTIONS = Object.freeze({
|
|
10
|
+
maxAttempts: 3,
|
|
11
|
+
baseDelayMs: 50,
|
|
12
|
+
maxDelayMs: 250,
|
|
13
|
+
});
|
|
14
|
+
const TIME_WINDOWS = Object.freeze([
|
|
15
|
+
{ name: 'overnight', start: 0, end: 5 },
|
|
16
|
+
{ name: 'morning', start: 6, end: 11 },
|
|
17
|
+
{ name: 'afternoon', start: 12, end: 17 },
|
|
18
|
+
{ name: 'evening', start: 18, end: 23 },
|
|
19
|
+
]);
|
|
20
|
+
const MEMORY_FINGERPRINT_CACHE = new WeakMap();
|
|
21
|
+
|
|
22
|
+
function clone(value) {
|
|
23
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toIsoTimestamp(value = Date.now()) {
|
|
27
|
+
const time = Number.isFinite(Number(value)) ? Number(value) : Date.now();
|
|
28
|
+
return new Date(time).toISOString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toTimestamp(value) {
|
|
32
|
+
if (value instanceof Date) return value.getTime();
|
|
33
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
34
|
+
if (typeof value === 'string') {
|
|
35
|
+
const parsed = Date.parse(value);
|
|
36
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeRetryOptions(options = {}) {
|
|
42
|
+
const maxAttempts = Math.max(1, Number(options.maxAttempts ?? DEFAULT_RETRY_OPTIONS.maxAttempts) || DEFAULT_RETRY_OPTIONS.maxAttempts);
|
|
43
|
+
const baseDelayMs = Math.max(0, Number(options.baseDelayMs ?? DEFAULT_RETRY_OPTIONS.baseDelayMs) || DEFAULT_RETRY_OPTIONS.baseDelayMs);
|
|
44
|
+
const maxDelayMs = Math.max(baseDelayMs, Number(options.maxDelayMs ?? DEFAULT_RETRY_OPTIONS.maxDelayMs) || DEFAULT_RETRY_OPTIONS.maxDelayMs);
|
|
45
|
+
return { maxAttempts, baseDelayMs, maxDelayMs };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeScope(scope) {
|
|
49
|
+
const text = String(scope ?? DEFAULT_SCOPE).trim();
|
|
50
|
+
return text || DEFAULT_SCOPE;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function metaKey(scope) {
|
|
54
|
+
return `${META_PREFIX}${normalizeScope(scope)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function hashValue(value) {
|
|
58
|
+
const hash = createHash('sha256');
|
|
59
|
+
hash.update(JSON.stringify(value));
|
|
60
|
+
return `sha256:${hash.digest('hex')}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeContextPath(value) {
|
|
64
|
+
const normalized = normalizePath(String(value ?? ''));
|
|
65
|
+
return normalized.replace(/\/+/gu, '/').replace(/\/+$/u, '') || '/';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toRelativePath(targetPath, cwd) {
|
|
69
|
+
if (!cwd) return targetPath;
|
|
70
|
+
const base = normalizeContextPath(cwd);
|
|
71
|
+
if (targetPath === base) return '.';
|
|
72
|
+
return targetPath.startsWith(`${base}/`) ? targetPath.slice(base.length + 1) : targetPath;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toUniqueList(values) {
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
const next = [];
|
|
78
|
+
for (const value of values) {
|
|
79
|
+
if (!value || seen.has(value)) continue;
|
|
80
|
+
seen.add(value);
|
|
81
|
+
next.push(value);
|
|
82
|
+
}
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectRawPathCandidates(context = {}) {
|
|
87
|
+
const direct = [context.file_path, context.filePath, context.path, context.target_path, context.targetPath];
|
|
88
|
+
const fromArrays = [context.files, context.paths, context.targets]
|
|
89
|
+
.filter(Array.isArray)
|
|
90
|
+
.flatMap((entry) => entry)
|
|
91
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry?.path));
|
|
92
|
+
return [...direct, ...fromArrays].filter((entry) => typeof entry === 'string' && entry.trim().length > 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function collectPathPattern(context = {}) {
|
|
96
|
+
const normalized = toUniqueList(
|
|
97
|
+
collectRawPathCandidates(context)
|
|
98
|
+
.map((entry) => normalizeContextPath(entry))
|
|
99
|
+
.map((entry) => toRelativePath(entry, context.cwd || context.project_root || context.projectRoot)),
|
|
100
|
+
).sort();
|
|
101
|
+
|
|
102
|
+
const primaryPath = normalized[0] ?? null;
|
|
103
|
+
const extensions = normalized
|
|
104
|
+
.map((entry) => entry.split('/').pop() || '')
|
|
105
|
+
.map((entry) => (entry.includes('.') ? entry.slice(entry.lastIndexOf('.')).toLowerCase() : 'none'));
|
|
106
|
+
const extensionCounts = extensions.reduce((acc, ext) => ({
|
|
107
|
+
...acc,
|
|
108
|
+
[ext]: (acc[ext] || 0) + 1,
|
|
109
|
+
}), {});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
count: normalized.length,
|
|
113
|
+
primary_path: primaryPath,
|
|
114
|
+
sample_paths: normalized.slice(0, 5),
|
|
115
|
+
extension_counts: extensionCounts,
|
|
116
|
+
checksum: hashValue(normalized),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeWorkType(value) {
|
|
121
|
+
const text = String(value ?? '').trim().toLowerCase();
|
|
122
|
+
if (!text) {
|
|
123
|
+
return { raw: null, normalized: 'general' };
|
|
124
|
+
}
|
|
125
|
+
const normalized = text.replace(/\s+/gu, '-').replace(/[^a-z0-9-]/gu, '') || 'general';
|
|
126
|
+
return { raw: value, normalized };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function collectActivityTimestamps(context = {}, now = Date.now) {
|
|
130
|
+
const nowValue = typeof now === 'function' ? now() : now;
|
|
131
|
+
const fromList = [context.activity_timestamps, context.activityTimestamps, context.timestamps]
|
|
132
|
+
.filter(Array.isArray)
|
|
133
|
+
.flatMap((entry) => entry)
|
|
134
|
+
.map(toTimestamp)
|
|
135
|
+
.filter((entry) => entry != null);
|
|
136
|
+
const singles = [context.timestamp, context.started_at, context.startedAt]
|
|
137
|
+
.map(toTimestamp)
|
|
138
|
+
.filter((entry) => entry != null);
|
|
139
|
+
return fromList.length || singles.length ? [...fromList, ...singles] : [Number(nowValue)];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function classifyHour(hour) {
|
|
143
|
+
const safeHour = Number.isFinite(Number(hour)) ? Number(hour) : 0;
|
|
144
|
+
const matched = TIME_WINDOWS.find((window) => safeHour >= window.start && safeHour <= window.end);
|
|
145
|
+
return matched?.name || 'overnight';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildWindowHistogram(timestamps = []) {
|
|
149
|
+
const histogram = TIME_WINDOWS.reduce((acc, window) => ({ ...acc, [window.name]: 0 }), {});
|
|
150
|
+
for (const timestamp of timestamps) {
|
|
151
|
+
const bucket = classifyHour(new Date(timestamp).getHours());
|
|
152
|
+
histogram[bucket] = (histogram[bucket] || 0) + 1;
|
|
153
|
+
}
|
|
154
|
+
return histogram;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function dominantWindow(histogram) {
|
|
158
|
+
const sorted = Object.entries(histogram)
|
|
159
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
160
|
+
return sorted[0]?.[0] || 'overnight';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveTimezoneName(context = {}) {
|
|
164
|
+
const fromContext = typeof context.timezone === 'string' ? context.timezone.trim() : '';
|
|
165
|
+
const fromIntl = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
166
|
+
return fromContext || fromIntl;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function collectTimezonePattern(context = {}, now = Date.now) {
|
|
170
|
+
const timestamps = collectActivityTimestamps(context, now);
|
|
171
|
+
const firstTimestamp = timestamps[0] ?? Date.now();
|
|
172
|
+
const histogram = buildWindowHistogram(timestamps);
|
|
173
|
+
return {
|
|
174
|
+
timezone: resolveTimezoneName(context),
|
|
175
|
+
offset_minutes: -new Date(firstTimestamp).getTimezoneOffset(),
|
|
176
|
+
sample_count: timestamps.length,
|
|
177
|
+
window_histogram: histogram,
|
|
178
|
+
dominant_window: dominantWindow(histogram),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function computeFingerprintSignature(input) {
|
|
183
|
+
const source = {
|
|
184
|
+
file_checksum: input.path_pattern.checksum,
|
|
185
|
+
work_type: input.work_type.normalized,
|
|
186
|
+
timezone: input.timezone_pattern.timezone,
|
|
187
|
+
dominant_window: input.timezone_pattern.dominant_window,
|
|
188
|
+
};
|
|
189
|
+
return hashValue(source);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getMemoryStoreMap(store) {
|
|
193
|
+
const current = MEMORY_FINGERPRINT_CACHE.get(store);
|
|
194
|
+
if (current) return current;
|
|
195
|
+
const next = new Map();
|
|
196
|
+
MEMORY_FINGERPRINT_CACHE.set(store, next);
|
|
197
|
+
return next;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readFingerprintFromStore(store, scope) {
|
|
201
|
+
if (!store) return null;
|
|
202
|
+
if (typeof store.loadAdaptiveFingerprint === 'function') {
|
|
203
|
+
return store.loadAdaptiveFingerprint(scope);
|
|
204
|
+
}
|
|
205
|
+
if (store.db?.prepare) {
|
|
206
|
+
const row = store.db.prepare('SELECT value FROM _meta WHERE key = ?').get(metaKey(scope));
|
|
207
|
+
return row?.value ? JSON.parse(row.value) : null;
|
|
208
|
+
}
|
|
209
|
+
return clone(getMemoryStoreMap(store).get(normalizeScope(scope)) || null);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function writeFingerprintToStore(store, scope, record) {
|
|
213
|
+
if (!store) return clone(record);
|
|
214
|
+
if (typeof store.saveAdaptiveFingerprint === 'function') {
|
|
215
|
+
return store.saveAdaptiveFingerprint(scope, clone(record));
|
|
216
|
+
}
|
|
217
|
+
if (store.db?.prepare) {
|
|
218
|
+
store.db.prepare('INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)')
|
|
219
|
+
.run(metaKey(scope), JSON.stringify(record));
|
|
220
|
+
return clone(record);
|
|
221
|
+
}
|
|
222
|
+
getMemoryStoreMap(store).set(normalizeScope(scope), clone(record));
|
|
223
|
+
return clone(record);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildHealthSnapshot(base, patch = {}) {
|
|
227
|
+
return {
|
|
228
|
+
state: patch.state || base.state || 'healthy',
|
|
229
|
+
retry: { ...base.retry },
|
|
230
|
+
last_success_at: patch.last_success_at ?? base.last_success_at ?? null,
|
|
231
|
+
last_failure_at: patch.last_failure_at ?? base.last_failure_at ?? null,
|
|
232
|
+
last_error: patch.last_error ?? base.last_error ?? null,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function createInitialHealth(retryOptions) {
|
|
237
|
+
return {
|
|
238
|
+
state: 'healthy',
|
|
239
|
+
retry: { ...retryOptions },
|
|
240
|
+
last_success_at: null,
|
|
241
|
+
last_failure_at: null,
|
|
242
|
+
last_error: null,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function resolveNowValue(now) {
|
|
247
|
+
return typeof now === 'function' ? now() : now;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function mergeFingerprintSnapshot(previous, computed, scope) {
|
|
251
|
+
return {
|
|
252
|
+
...computed,
|
|
253
|
+
scope,
|
|
254
|
+
observation_count: (previous?.observation_count || 0) + 1,
|
|
255
|
+
first_captured_at: previous?.first_captured_at || computed.captured_at,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function markHealthyHealth(base, now) {
|
|
260
|
+
return buildHealthSnapshot(base, {
|
|
261
|
+
state: 'healthy',
|
|
262
|
+
last_success_at: toIsoTimestamp(resolveNowValue(now)),
|
|
263
|
+
last_error: null,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function markDegradedHealth(base, now, error) {
|
|
268
|
+
return buildHealthSnapshot(base, {
|
|
269
|
+
state: 'degraded',
|
|
270
|
+
last_failure_at: toIsoTimestamp(resolveNowValue(now)),
|
|
271
|
+
last_error: {
|
|
272
|
+
name: error?.name || 'Error',
|
|
273
|
+
message: error?.message || 'unknown adaptive fingerprint error',
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function buildAdaptiveFingerprint(sessionContext = {}, options = {}) {
|
|
279
|
+
const now = options.now ?? Date.now;
|
|
280
|
+
const capturedAt = toIsoTimestamp(resolveNowValue(now));
|
|
281
|
+
const pathPattern = collectPathPattern(sessionContext);
|
|
282
|
+
const workType = normalizeWorkType(sessionContext.work_type ?? sessionContext.workType);
|
|
283
|
+
const timezonePattern = collectTimezonePattern(sessionContext, now);
|
|
284
|
+
const fingerprintId = computeFingerprintSignature({
|
|
285
|
+
path_pattern: pathPattern,
|
|
286
|
+
work_type: workType,
|
|
287
|
+
timezone_pattern: timezonePattern,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
version: ADAPTIVE_FINGERPRINT_VERSION,
|
|
292
|
+
captured_at: capturedAt,
|
|
293
|
+
scope: normalizeScope(sessionContext.scope),
|
|
294
|
+
fingerprint_id: fingerprintId,
|
|
295
|
+
path_pattern: pathPattern,
|
|
296
|
+
work_type: workType,
|
|
297
|
+
timezone_pattern: timezonePattern,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function loadAdaptiveFingerprint(store, scope = DEFAULT_SCOPE) {
|
|
302
|
+
const loaded = await Promise.resolve(readFingerprintFromStore(store, scope));
|
|
303
|
+
return clone(loaded);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function saveAdaptiveFingerprint(store, scope, fingerprint, options = {}) {
|
|
307
|
+
const retryOptions = normalizeRetryOptions(options.retryOptions);
|
|
308
|
+
const normalizedScope = normalizeScope(scope);
|
|
309
|
+
const write = async () => Promise.resolve(writeFingerprintToStore(store, normalizedScope, fingerprint));
|
|
310
|
+
const saved = await withRetry(write, { ...retryOptions });
|
|
311
|
+
return clone(saved);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function createAdaptiveFingerprintService(options = {}) {
|
|
315
|
+
const store = options.store ?? null;
|
|
316
|
+
const retryOptions = normalizeRetryOptions(options.retryOptions);
|
|
317
|
+
const now = options.now ?? Date.now;
|
|
318
|
+
let health = createInitialHealth(retryOptions);
|
|
319
|
+
|
|
320
|
+
async function capture(sessionContext = {}) {
|
|
321
|
+
const computed = buildAdaptiveFingerprint(sessionContext, { now });
|
|
322
|
+
const scope = normalizeScope(sessionContext.scope ?? computed.scope);
|
|
323
|
+
const previous = await loadAdaptiveFingerprint(store, scope);
|
|
324
|
+
const merged = mergeFingerprintSnapshot(previous, computed, scope);
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const saved = await saveAdaptiveFingerprint(store, scope, merged, { retryOptions });
|
|
328
|
+
health = markHealthyHealth(health, now);
|
|
329
|
+
return saved;
|
|
330
|
+
} catch (error) {
|
|
331
|
+
health = markDegradedHealth(health, now, error);
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function read(scope = DEFAULT_SCOPE) {
|
|
337
|
+
return loadAdaptiveFingerprint(store, scope);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function getHealth() {
|
|
341
|
+
return clone(health);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return Object.freeze({
|
|
345
|
+
captureFingerprint: capture,
|
|
346
|
+
computeFingerprint: (sessionContext = {}) => buildAdaptiveFingerprint(sessionContext, { now }),
|
|
347
|
+
loadFingerprint: read,
|
|
348
|
+
getHealth,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export default createAdaptiveFingerprintService;
|
package/hub/state.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, openSync, closeSync, unlinkSync, writeFileSync, readFileSync, renameSync, existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
8
|
+
const STATE_FILE_NAME = 'hub-state.json';
|
|
9
|
+
const LOCK_FILE_NAME = 'hub-start.lock';
|
|
10
|
+
|
|
11
|
+
let heldLockPath = null;
|
|
12
|
+
let heldLockFd = null;
|
|
13
|
+
let cachedVersionHash = null;
|
|
14
|
+
|
|
15
|
+
function getStateDir(options = {}) {
|
|
16
|
+
return options.stateDir || process.env.TFX_HUB_STATE_DIR?.trim() || join(homedir(), '.claude', 'cache', 'tfx-hub');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getStatePath(options = {}) {
|
|
20
|
+
return join(getStateDir(options), STATE_FILE_NAME);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getLockPath(options = {}) {
|
|
24
|
+
return options.lockPath || join(getStateDir(options), LOCK_FILE_NAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sleep(ms) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPidAlive(pid) {
|
|
32
|
+
if (!Number.isFinite(Number(pid)) || Number(pid) <= 0) return false;
|
|
33
|
+
try {
|
|
34
|
+
process.kill(Number(pid), 0);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseJson(text, fallback = null) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(text);
|
|
44
|
+
} catch {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function safeReplaceFile(tempPath, targetPath) {
|
|
50
|
+
try {
|
|
51
|
+
renameSync(tempPath, targetPath);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (!['EEXIST', 'EPERM', 'EACCES'].includes(error?.code)) {
|
|
54
|
+
try { unlinkSync(tempPath); } catch {}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
try { unlinkSync(targetPath); } catch {}
|
|
58
|
+
renameSync(tempPath, targetPath);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 허브의 현재 상태(PID, 포트, 버전 등)를 파일에 기록합니다.
|
|
64
|
+
* 원자적(atomic) 쓰기를 위해 임시 파일을 생성한 후 교체하는 방식을 사용합니다.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} payload - 상태 데이터
|
|
67
|
+
* @param {number} payload.pid - 허브 프로세스 ID
|
|
68
|
+
* @param {number} payload.port - 허브 서버 포트
|
|
69
|
+
* @param {string} payload.version - 허브 버전
|
|
70
|
+
* @param {string} payload.sessionId - 현재 세션 ID
|
|
71
|
+
* @param {string} payload.startedAt - 시작 시각 (ISO 8601)
|
|
72
|
+
* @param {object} [options] - 옵션
|
|
73
|
+
* @param {string} [options.stateDir] - 상태 파일이 저장될 디렉토리
|
|
74
|
+
* @returns {object} 기록된 상태 데이터
|
|
75
|
+
*/
|
|
76
|
+
export function writeState({ pid, port, version, sessionId, startedAt }, options = {}) {
|
|
77
|
+
const stateDir = getStateDir(options);
|
|
78
|
+
const statePath = getStatePath(options);
|
|
79
|
+
const tempPath = join(stateDir, `${STATE_FILE_NAME}.${process.pid}.${Date.now()}.tmp`);
|
|
80
|
+
const payload = { pid, port, version, sessionId, startedAt };
|
|
81
|
+
|
|
82
|
+
mkdirSync(stateDir, { recursive: true });
|
|
83
|
+
writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
84
|
+
safeReplaceFile(tempPath, statePath);
|
|
85
|
+
return payload;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 파일로부터 허브의 현재 상태를 읽어옵니다.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} [options] - 옵션
|
|
92
|
+
* @param {string} [options.stateDir] - 상태 파일이 저장된 디렉토리
|
|
93
|
+
* @returns {object|null} 읽어온 상태 데이터 또는 실패 시 null
|
|
94
|
+
*/
|
|
95
|
+
export function readState(options = {}) {
|
|
96
|
+
const statePath = getStatePath(options);
|
|
97
|
+
try {
|
|
98
|
+
if (!existsSync(statePath)) return null;
|
|
99
|
+
return parseJson(readFileSync(statePath, 'utf8'), null);
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 지정된 포트에서 실행 중인 허브 서버의 헬스 체크를 수행합니다.
|
|
107
|
+
*
|
|
108
|
+
* @param {number|string} port - 서버 포트
|
|
109
|
+
* @param {object} [options] - 옵션
|
|
110
|
+
* @param {number} [options.timeoutMs=1000] - 요청 타임아웃
|
|
111
|
+
* @param {string} [options.baseUrl] - 서버 베이스 URL
|
|
112
|
+
* @returns {Promise<boolean>} 서버 정상 작동 여부
|
|
113
|
+
*/
|
|
114
|
+
export async function isServerHealthy(port, options = {}) {
|
|
115
|
+
const resolvedPort = Number(port);
|
|
116
|
+
if (!Number.isFinite(resolvedPort) || resolvedPort <= 0) return false;
|
|
117
|
+
|
|
118
|
+
const timeoutMs = Math.max(100, Number(options.timeoutMs) || 1000);
|
|
119
|
+
const baseUrl = options.baseUrl || `http://127.0.0.1:${resolvedPort}`;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(`${baseUrl}/health`, {
|
|
123
|
+
method: 'GET',
|
|
124
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
125
|
+
});
|
|
126
|
+
if (!response.ok) return false;
|
|
127
|
+
const body = await response.json().catch(() => null);
|
|
128
|
+
return body?.ok === true;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 현재 프로젝트의 버전 해시를 생성합니다.
|
|
136
|
+
* package.json의 버전과 Git commit SHA를 조합합니다.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} [options] - 옵션
|
|
139
|
+
* @param {boolean} [options.force=false] - 캐시를 무시하고 새로 생성할지 여부
|
|
140
|
+
* @returns {string} 버전 해시 문자열
|
|
141
|
+
*/
|
|
142
|
+
export function getVersionHash(options = {}) {
|
|
143
|
+
if (cachedVersionHash && !options.force) return cachedVersionHash;
|
|
144
|
+
|
|
145
|
+
const packageJsonPath = join(PROJECT_ROOT, 'package.json');
|
|
146
|
+
const pkg = parseJson(readFileSync(packageJsonPath, 'utf8'), {});
|
|
147
|
+
const version = String(pkg?.version || '0.0.0').trim();
|
|
148
|
+
|
|
149
|
+
let sha = String(process.env.TFX_HUB_GIT_SHA || '').trim();
|
|
150
|
+
if (!sha) {
|
|
151
|
+
try {
|
|
152
|
+
sha = execSync('git rev-parse --short HEAD', {
|
|
153
|
+
cwd: PROJECT_ROOT,
|
|
154
|
+
encoding: 'utf8',
|
|
155
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
156
|
+
windowsHide: true,
|
|
157
|
+
}).trim();
|
|
158
|
+
} catch {
|
|
159
|
+
sha = '';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
cachedVersionHash = sha ? `${version}-${sha}` : version;
|
|
164
|
+
return cachedVersionHash;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 허브 시작 시 중복 실행을 방지하기 위한 잠금(lock)을 획득합니다.
|
|
169
|
+
* 이미 실행 중인 다른 프로세스가 있는지 확인하고 유효한 잠금을 획득할 때까지 재시도합니다.
|
|
170
|
+
*
|
|
171
|
+
* @param {object} [options] - 옵션
|
|
172
|
+
* @param {number} [options.timeoutMs=3000] - 최대 대기 시간
|
|
173
|
+
* @param {number} [options.pollMs=50] - 재시도 간격
|
|
174
|
+
* @param {string} [options.lockPath] - 잠금 파일 경로
|
|
175
|
+
* @returns {Promise<{path: string}>} 잠금 파일 경로
|
|
176
|
+
* @throws {Error} 타임아웃 내에 잠금을 획득하지 못한 경우
|
|
177
|
+
*/
|
|
178
|
+
export async function acquireLock(options = {}) {
|
|
179
|
+
if (heldLockFd !== null && heldLockPath) {
|
|
180
|
+
return { path: heldLockPath };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const lockPath = getLockPath(options);
|
|
184
|
+
const timeoutMs = Math.max(100, Number(options.timeoutMs) || 3000);
|
|
185
|
+
const pollMs = Math.max(10, Number(options.pollMs) || 50);
|
|
186
|
+
const deadline = Date.now() + timeoutMs;
|
|
187
|
+
|
|
188
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
189
|
+
|
|
190
|
+
while (Date.now() <= deadline) {
|
|
191
|
+
try {
|
|
192
|
+
const fd = openSync(lockPath, 'wx', 0o600);
|
|
193
|
+
writeFileSync(fd, `${JSON.stringify({
|
|
194
|
+
pid: process.pid,
|
|
195
|
+
createdAt: new Date().toISOString(),
|
|
196
|
+
}, null, 2)}\n`, 'utf8');
|
|
197
|
+
heldLockFd = fd;
|
|
198
|
+
heldLockPath = lockPath;
|
|
199
|
+
return { path: lockPath };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error?.code !== 'EEXIST') {
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const raw = readFileSync(lockPath, 'utf8');
|
|
207
|
+
const data = parseJson(raw, {});
|
|
208
|
+
const stats = statSync(lockPath);
|
|
209
|
+
const staleByPid = !isPidAlive(data?.pid);
|
|
210
|
+
const staleByAge = Date.now() - stats.mtimeMs > timeoutMs;
|
|
211
|
+
if (staleByPid || staleByAge) {
|
|
212
|
+
try { unlinkSync(lockPath); } catch {}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
} catch {}
|
|
216
|
+
|
|
217
|
+
await sleep(pollMs);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
throw new Error(`hub start lock busy: ${lockPath}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 획득했던 잠금을 해제합니다. 잠금 파일을 삭제하고 관련 리소스를 정리합니다.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} [options] - 옵션
|
|
228
|
+
* @param {string} [options.lockPath] - 명시적인 잠금 파일 경로
|
|
229
|
+
*/
|
|
230
|
+
export function releaseLock(options = {}) {
|
|
231
|
+
const lockPath = options.lockPath || heldLockPath || getLockPath(options);
|
|
232
|
+
|
|
233
|
+
if (heldLockFd !== null) {
|
|
234
|
+
try { closeSync(heldLockFd); } catch {}
|
|
235
|
+
heldLockFd = null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
if (existsSync(lockPath)) unlinkSync(lockPath);
|
|
240
|
+
} catch {}
|
|
241
|
+
|
|
242
|
+
if (!options.lockPath || options.lockPath === heldLockPath) {
|
|
243
|
+
heldLockPath = null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// hub/team-bridge.mjs — core ↔ team 디커플링 인터페이스
|
|
2
|
+
// pipe.mjs, tools.mjs가 team/nativeProxy를 직접 참조하지 않도록
|
|
3
|
+
// registry 패턴으로 구현을 주입받는다.
|
|
4
|
+
// remote 미설치 시 graceful no-op 반환.
|
|
5
|
+
|
|
6
|
+
const noTeam = async () => ({
|
|
7
|
+
ok: false,
|
|
8
|
+
error: { code: 'NO_TEAM', message: 'Team module not loaded' },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
let _impl = {
|
|
12
|
+
teamInfo: noTeam,
|
|
13
|
+
teamTaskList: noTeam,
|
|
14
|
+
teamTaskUpdate: noTeam,
|
|
15
|
+
teamSendMessage: noTeam,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function registerTeamBridge(impl) {
|
|
19
|
+
_impl = { ..._impl, ...impl };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function teamInfo(args) { return _impl.teamInfo(args); }
|
|
23
|
+
export function teamTaskList(args) { return _impl.teamTaskList(args); }
|
|
24
|
+
export function teamTaskUpdate(args) { return _impl.teamTaskUpdate(args); }
|
|
25
|
+
export function teamSendMessage(args) { return _impl.teamSendMessage(args); }
|