triflux 10.3.1 → 10.3.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +16 -0
- package/README.md +8 -0
- package/hub/team/ansi.mjs +44 -28
- package/hub/team/conductor.mjs +2 -2
- package/hub/team/tui-lite.mjs +4 -4
- package/hub/team/tui.mjs +16 -12
- package/hud/constants.mjs +8 -2
- package/hud/providers/codex.mjs +11 -0
- package/hud/providers/gemini.mjs +21 -0
- package/mesh/index.mjs +63 -0
- package/mesh/mesh-budget.mjs +128 -0
- package/mesh/mesh-heartbeat.mjs +100 -0
- package/mesh/mesh-protocol.mjs +96 -0
- package/mesh/mesh-queue.mjs +165 -0
- package/mesh/mesh-registry.mjs +78 -0
- package/mesh/mesh-router.mjs +76 -0
- package/package.json +2 -1
- package/scripts/claudemd-sync.mjs +11 -24
- package/scripts/setup.mjs +23 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
{
|
|
10
10
|
"name": "triflux",
|
|
11
11
|
"description": "CLI-first multi-model orchestrator for Claude Code. Routes tasks to Codex, Gemini, and Claude CLIs with automatic triage, DAG-based parallel execution, headless psmux sessions, and cost-optimized routing. Includes 41 skills, HUD status bar, hook orchestrator, and shell-based CLI routing.",
|
|
12
|
-
"version": "
|
|
12
|
+
"version": "10.3.2",
|
|
13
13
|
"author": {
|
|
14
14
|
"name": "tellang"
|
|
15
15
|
},
|
|
@@ -30,5 +30,5 @@
|
|
|
30
30
|
]
|
|
31
31
|
}
|
|
32
32
|
],
|
|
33
|
-
"version": "
|
|
33
|
+
"version": "10.3.2"
|
|
34
34
|
}
|
package/README.ko.md
CHANGED
|
@@ -39,6 +39,14 @@
|
|
|
39
39
|
|
|
40
40
|
### 1. 설치
|
|
41
41
|
|
|
42
|
+
**Claude Code 플러그인** (권장):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
claude plugin add triflux
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**npm 글로벌**:
|
|
49
|
+
|
|
42
50
|
```bash
|
|
43
51
|
npm install -g triflux
|
|
44
52
|
```
|
|
@@ -286,6 +294,14 @@ graph TD
|
|
|
286
294
|
|
|
287
295
|
### 1. 설치
|
|
288
296
|
|
|
297
|
+
**Claude Code 플러그인** (권장):
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
claude plugin add triflux
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**npm 글로벌**:
|
|
304
|
+
|
|
289
305
|
```bash
|
|
290
306
|
npm install -g triflux
|
|
291
307
|
```
|
package/README.md
CHANGED
|
@@ -63,6 +63,14 @@ You don't need to memorize commands. Say what you want in natural language — t
|
|
|
63
63
|
|
|
64
64
|
### 1. Install
|
|
65
65
|
|
|
66
|
+
**Claude Code Plugin** (recommended):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
claude plugin add triflux
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**npm global**:
|
|
73
|
+
|
|
66
74
|
```bash
|
|
67
75
|
npm install -g triflux
|
|
68
76
|
```
|
package/hub/team/ansi.mjs
CHANGED
|
@@ -249,39 +249,55 @@ export function padRight(str, len) {
|
|
|
249
249
|
return str + " ".repeat(pad);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
let acc = 0;
|
|
252
|
+
// ANSI-preserving slice: ANSI escape를 보존하면서 visible width만큼 자름
|
|
253
|
+
function ansiSlice(str, maxWidth) {
|
|
254
|
+
let result = "";
|
|
255
|
+
let visWidth = 0;
|
|
256
|
+
let hasAnsi = false;
|
|
259
257
|
let i = 0;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
258
|
+
|
|
259
|
+
while (i < str.length) {
|
|
260
|
+
if (str.charCodeAt(i) === 0x1B) {
|
|
261
|
+
const rest = str.slice(i);
|
|
262
|
+
const m = rest.match(/^(\x1b(?:\[[0-9;]*[a-zA-Z]|\].*?(?:\x07|\x1b\\)))/);
|
|
263
|
+
if (m) {
|
|
264
|
+
result += m[1];
|
|
265
|
+
hasAnsi = true;
|
|
266
|
+
i += m[1].length;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const cp = str.codePointAt(i);
|
|
272
|
+
const charLen = cp > 0xFFFF ? 2 : 1;
|
|
273
|
+
const cw = charWidth(cp);
|
|
274
|
+
|
|
275
|
+
if (visWidth + cw > maxWidth) break;
|
|
276
|
+
|
|
277
|
+
result += str.slice(i, i + charLen);
|
|
278
|
+
visWidth += cw;
|
|
279
|
+
i += charLen;
|
|
265
280
|
}
|
|
266
|
-
|
|
281
|
+
|
|
282
|
+
return { result, visWidth, hasAnsi };
|
|
267
283
|
}
|
|
268
284
|
|
|
269
|
-
// wcwidth-aware
|
|
285
|
+
// wcwidth-aware truncate: wide char 경계에서 자름 (ANSI 보존)
|
|
286
|
+
export function truncate(str, maxLen) {
|
|
287
|
+
if (wcswidth(str) <= maxLen) return str;
|
|
288
|
+
|
|
289
|
+
const { result, hasAnsi } = ansiSlice(str, maxLen - 1);
|
|
290
|
+
return hasAnsi ? result + RESET + "…" : result + "…";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// wcwidth-aware clip: 정확히 width 셀에 맞게 자르고 패딩 (ANSI 보존, wide char 경계 보정)
|
|
270
294
|
export function clip(str, width) {
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
// wide char이 경계를 넘으면 공백으로 채움
|
|
278
|
-
const result = plain.slice(0, i) + " ".repeat(width - acc);
|
|
279
|
-
return result;
|
|
280
|
-
}
|
|
281
|
-
acc += cw;
|
|
282
|
-
i += char.length;
|
|
283
|
-
}
|
|
284
|
-
return str + " ".repeat(width - acc);
|
|
295
|
+
const vis = wcswidth(str);
|
|
296
|
+
if (vis <= width) return str + " ".repeat(width - vis);
|
|
297
|
+
|
|
298
|
+
const { result, visWidth, hasAnsi } = ansiSlice(str, width);
|
|
299
|
+
const suffix = hasAnsi ? RESET : "";
|
|
300
|
+
return result + suffix + " ".repeat(Math.max(0, width - visWidth));
|
|
285
301
|
}
|
|
286
302
|
|
|
287
303
|
// ── Catppuccin Mocha 색상 상수 ──
|
package/hub/team/conductor.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { createRegistry } from "../../mesh/mesh-registry.mjs";
|
|
|
22
22
|
import { broker } from "../account-broker.mjs";
|
|
23
23
|
import { killProcess } from "../platform.mjs";
|
|
24
24
|
import { createConductorMeshBridge } from "./conductor-mesh-bridge.mjs";
|
|
25
|
-
import { getConductorRegistry } from "./conductor-registry.mjs";
|
|
25
|
+
import { ensureConductorRegistry, getConductorRegistry } from "./conductor-registry.mjs";
|
|
26
26
|
import { createEventLog } from "./event-log.mjs";
|
|
27
27
|
import { createHealthProbe } from "./health-probe.mjs";
|
|
28
28
|
import { buildLauncher } from "./launcher-template.mjs";
|
|
@@ -797,6 +797,6 @@ export function createConductor(opts = {}) {
|
|
|
797
797
|
}
|
|
798
798
|
|
|
799
799
|
const frozenApi = Object.freeze(conductor);
|
|
800
|
-
|
|
800
|
+
ensureConductorRegistry();
|
|
801
801
|
return frozenApi;
|
|
802
802
|
}
|
package/hub/team/tui-lite.mjs
CHANGED
|
@@ -265,12 +265,12 @@ export function createLiteDashboard(opts = {}) {
|
|
|
265
265
|
return;
|
|
266
266
|
}
|
|
267
267
|
if (key === "\r" || key === "\n") {
|
|
268
|
-
if (typeof onOpenSelectedWorker
|
|
269
|
-
|
|
268
|
+
if (typeof onOpenSelectedWorker === "function" && selectedWorker && workers.has(selectedWorker)) {
|
|
269
|
+
triggerOpenSelected();
|
|
270
|
+
} else {
|
|
271
|
+
// 콜백 없거나 선택 워커 없으면 탭 순환
|
|
270
272
|
const tabs = ["log", "detail", "files"];
|
|
271
273
|
focusTab = tabs[(tabs.indexOf(focusTab) + 1) % tabs.length];
|
|
272
|
-
} else {
|
|
273
|
-
triggerOpenSelected();
|
|
274
274
|
}
|
|
275
275
|
render();
|
|
276
276
|
return;
|
package/hub/team/tui.mjs
CHANGED
|
@@ -1148,15 +1148,19 @@ export function createLogDashboard(opts = {}) {
|
|
|
1148
1148
|
}
|
|
1149
1149
|
|
|
1150
1150
|
// 하단 상태바
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
);
|
|
1151
|
+
const allDone = names.every((n) => {
|
|
1152
|
+
const s = runtimeStatus(workers.get(n));
|
|
1153
|
+
return s === "ok" || s === "completed" || s === "failed";
|
|
1154
|
+
});
|
|
1155
|
+
const statusText = allDone
|
|
1156
|
+
? " Enter: attach • q: 종료"
|
|
1157
|
+
: " Enter: attach • Tab: 포커스 • j/k: 이동 • h: 도움말";
|
|
1158
|
+
const statusBar = truncate(color(statusText, MOCHA.subtext), totalCols);
|
|
1155
1159
|
|
|
1156
1160
|
return [...tier1, ...composedRows, statusBar];
|
|
1157
1161
|
}
|
|
1158
1162
|
|
|
1159
|
-
// ── altScreen diff render
|
|
1163
|
+
// ── altScreen diff render (batched single write → 깜빡임 방지) ───────
|
|
1160
1164
|
function renderAltScreen() {
|
|
1161
1165
|
const newRows = buildRows();
|
|
1162
1166
|
rowBuf.set(newRows);
|
|
@@ -1165,16 +1169,16 @@ export function createLogDashboard(opts = {}) {
|
|
|
1165
1169
|
|
|
1166
1170
|
if (dirty.length === 0 && newRows.length === prevLen) return;
|
|
1167
1171
|
|
|
1168
|
-
|
|
1169
|
-
? Array.from({ length: prevLen - newRows.length }, (_, i) => newRows.length + i)
|
|
1170
|
-
: [];
|
|
1171
|
-
|
|
1172
|
+
let buf = "";
|
|
1172
1173
|
for (const i of dirty) {
|
|
1173
|
-
|
|
1174
|
+
buf += moveTo(i + 1, 1) + clearLine + (newRows[i] || "");
|
|
1174
1175
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1176
|
+
if (prevLen > newRows.length) {
|
|
1177
|
+
for (let i = newRows.length; i < prevLen; i++) {
|
|
1178
|
+
buf += moveTo(i + 1, 1) + clearLine;
|
|
1179
|
+
}
|
|
1177
1180
|
}
|
|
1181
|
+
if (buf) write(buf);
|
|
1178
1182
|
|
|
1179
1183
|
rowBuf.commit();
|
|
1180
1184
|
}
|
package/hud/constants.mjs
CHANGED
|
@@ -39,9 +39,15 @@ export const DEFAULT_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
|
39
39
|
|
|
40
40
|
export const CODEX_AUTH_PATH = join(homedir(), ".codex", "auth.json");
|
|
41
41
|
export const CODEX_QUOTA_CACHE_PATH = join(homedir(), ".claude", "cache", "codex-rate-limits-cache.json");
|
|
42
|
-
export const CODEX_QUOTA_STALE_MS =
|
|
42
|
+
export const CODEX_QUOTA_STALE_MS = 30 * 1000; // 30초
|
|
43
43
|
export const CODEX_MIN_BUCKETS = 2;
|
|
44
44
|
|
|
45
|
+
// Spawn lock (중복 refresh 방지)
|
|
46
|
+
export const CODEX_REFRESH_LOCK_PATH = join(homedir(), ".claude", "cache", ".codex-refresh-lock");
|
|
47
|
+
export const GEMINI_QUOTA_REFRESH_LOCK_PATH = join(homedir(), ".claude", "cache", ".gemini-quota-refresh-lock");
|
|
48
|
+
export const GEMINI_SESSION_REFRESH_LOCK_PATH = join(homedir(), ".claude", "cache", ".gemini-session-refresh-lock");
|
|
49
|
+
export const SPAWN_LOCK_TTL_MS = 30 * 1000; // 30초 spawn dedup
|
|
50
|
+
|
|
45
51
|
// Gemini 쿼터 API 관련
|
|
46
52
|
export const GEMINI_OAUTH_PATH = join(homedir(), ".gemini", "oauth_creds.json");
|
|
47
53
|
export const GEMINI_QUOTA_CACHE_PATH = join(homedir(), ".claude", "cache", "gemini-quota-cache.json");
|
|
@@ -58,7 +64,7 @@ export const LEGACY_SV_ACCUMULATOR = join(homedir(), ".omc", "state", "sv-accumu
|
|
|
58
64
|
|
|
59
65
|
export const GEMINI_RPM_WINDOW_MS = 60 * 1000; // 60초 슬라이딩 윈도우
|
|
60
66
|
export const GEMINI_QUOTA_STALE_MS = 5 * 60 * 1000; // 5분
|
|
61
|
-
export const GEMINI_SESSION_STALE_MS =
|
|
67
|
+
export const GEMINI_SESSION_STALE_MS = 30 * 1000; // 30초
|
|
62
68
|
export const GEMINI_API_TIMEOUT_MS = 3000; // 3초
|
|
63
69
|
|
|
64
70
|
export const ACCOUNT_LABEL_WIDTH = 10;
|
package/hud/providers/codex.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import {
|
|
9
9
|
CODEX_AUTH_PATH, CODEX_QUOTA_CACHE_PATH, CODEX_QUOTA_STALE_MS,
|
|
10
10
|
CODEX_MIN_BUCKETS, CODEX_REFRESH_FLAG,
|
|
11
|
+
CODEX_REFRESH_LOCK_PATH, SPAWN_LOCK_TTL_MS,
|
|
11
12
|
} from "../constants.mjs";
|
|
12
13
|
import { readJson, writeJsonSafe, decodeJwtEmail } from "../utils.mjs";
|
|
13
14
|
|
|
@@ -154,6 +155,16 @@ export function refreshCodexRateLimitsCache() {
|
|
|
154
155
|
export function scheduleCodexRateLimitRefresh() {
|
|
155
156
|
const scriptPath = process.argv[1];
|
|
156
157
|
if (!scriptPath) return;
|
|
158
|
+
|
|
159
|
+
// 스폰 락: 30초 내 이미 스폰했으면 중복 방지
|
|
160
|
+
try {
|
|
161
|
+
if (existsSync(CODEX_REFRESH_LOCK_PATH)) {
|
|
162
|
+
const lockAge = Date.now() - readJson(CODEX_REFRESH_LOCK_PATH, {}).t;
|
|
163
|
+
if (lockAge < SPAWN_LOCK_TTL_MS) return;
|
|
164
|
+
}
|
|
165
|
+
writeJsonSafe(CODEX_REFRESH_LOCK_PATH, { t: Date.now() });
|
|
166
|
+
} catch { /* 락 실패 무시 — 스폰 진행 */ }
|
|
167
|
+
|
|
157
168
|
try {
|
|
158
169
|
const child = spawn(process.execPath, [scriptPath, CODEX_REFRESH_FLAG], {
|
|
159
170
|
detached: true,
|
package/hud/providers/gemini.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
GEMINI_RPM_WINDOW_MS, GEMINI_QUOTA_STALE_MS, GEMINI_SESSION_STALE_MS,
|
|
15
15
|
GEMINI_API_TIMEOUT_MS,
|
|
16
16
|
GEMINI_REFRESH_FLAG, GEMINI_SESSION_REFRESH_FLAG,
|
|
17
|
+
GEMINI_QUOTA_REFRESH_LOCK_PATH, GEMINI_SESSION_REFRESH_LOCK_PATH, SPAWN_LOCK_TTL_MS,
|
|
17
18
|
} from "../constants.mjs";
|
|
18
19
|
import {
|
|
19
20
|
readJson, writeJsonSafe, readJsonMigrate, makeHash, clampPercent,
|
|
@@ -233,6 +234,16 @@ export function readGeminiQuotaSnapshot(accountId, authContext) {
|
|
|
233
234
|
export function scheduleGeminiQuotaRefresh(accountId) {
|
|
234
235
|
const scriptPath = process.argv[1];
|
|
235
236
|
if (!scriptPath) return;
|
|
237
|
+
|
|
238
|
+
// 스폰 락: 30초 내 이미 스폰했으면 중복 방지
|
|
239
|
+
try {
|
|
240
|
+
if (existsSync(GEMINI_QUOTA_REFRESH_LOCK_PATH)) {
|
|
241
|
+
const lockAge = Date.now() - readJson(GEMINI_QUOTA_REFRESH_LOCK_PATH, {}).t;
|
|
242
|
+
if (lockAge < SPAWN_LOCK_TTL_MS) return;
|
|
243
|
+
}
|
|
244
|
+
writeJsonSafe(GEMINI_QUOTA_REFRESH_LOCK_PATH, { t: Date.now() });
|
|
245
|
+
} catch { /* 락 실패 무시 — 스폰 진행 */ }
|
|
246
|
+
|
|
236
247
|
try {
|
|
237
248
|
const child = spawn(
|
|
238
249
|
process.execPath,
|
|
@@ -275,6 +286,16 @@ export function refreshGeminiSessionCache() {
|
|
|
275
286
|
export function scheduleGeminiSessionRefresh() {
|
|
276
287
|
const scriptPath = process.argv[1];
|
|
277
288
|
if (!scriptPath) return;
|
|
289
|
+
|
|
290
|
+
// 스폰 락: 30초 내 이미 스폰했으면 중복 방지
|
|
291
|
+
try {
|
|
292
|
+
if (existsSync(GEMINI_SESSION_REFRESH_LOCK_PATH)) {
|
|
293
|
+
const lockAge = Date.now() - readJson(GEMINI_SESSION_REFRESH_LOCK_PATH, {}).t;
|
|
294
|
+
if (lockAge < SPAWN_LOCK_TTL_MS) return;
|
|
295
|
+
}
|
|
296
|
+
writeJsonSafe(GEMINI_SESSION_REFRESH_LOCK_PATH, { t: Date.now() });
|
|
297
|
+
} catch { /* 락 실패 무시 — 스폰 진행 */ }
|
|
298
|
+
|
|
278
299
|
try {
|
|
279
300
|
const child = spawn(process.execPath, [scriptPath, GEMINI_SESSION_REFRESH_FLAG], {
|
|
280
301
|
detached: true,
|
package/mesh/index.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export { MSG_TYPES, createMessage, serialize, deserialize, validate } from "./mesh-protocol.mjs";
|
|
5
|
+
export { createRegistry } from "./mesh-registry.mjs";
|
|
6
|
+
export { createMeshBudget } from "./mesh-budget.mjs";
|
|
7
|
+
export { routeMessage, routeOrDeadLetter } from "./mesh-router.mjs";
|
|
8
|
+
export { createMessageQueue } from "./mesh-queue.mjs";
|
|
9
|
+
export { createHeartbeatMonitor } from "./mesh-heartbeat.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Loads skills assigned to a specific agent from a skills directory.
|
|
13
|
+
* Reuses the same directory-scan approach as generateSkillDocs().
|
|
14
|
+
*
|
|
15
|
+
* @param {string} agentId - The agent identifier
|
|
16
|
+
* @param {string} skillsDir - Path to the skills directory
|
|
17
|
+
* @returns {Promise<string[]>} Array of skill names available to this agent
|
|
18
|
+
*/
|
|
19
|
+
export async function loadSkillsForAgent(agentId, skillsDir) {
|
|
20
|
+
if (!agentId || typeof agentId !== "string") {
|
|
21
|
+
throw new TypeError("agentId must be a non-empty string");
|
|
22
|
+
}
|
|
23
|
+
if (!skillsDir || typeof skillsDir !== "string") {
|
|
24
|
+
throw new TypeError("skillsDir must be a non-empty string");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = readdirSync(skillsDir, { withFileTypes: true });
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const skills = [];
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (!entry.isDirectory()) continue;
|
|
37
|
+
const skillName = entry.name;
|
|
38
|
+
const skillPath = join(skillsDir, skillName, "SKILL.md");
|
|
39
|
+
let skillContent = null;
|
|
40
|
+
try {
|
|
41
|
+
const { readFileSync } = await import("node:fs");
|
|
42
|
+
skillContent = readFileSync(skillPath, "utf8");
|
|
43
|
+
} catch {
|
|
44
|
+
// Skill has no SKILL.md — include it anyway
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If SKILL.md mentions the agentId or no agent restriction, include it
|
|
48
|
+
const isRestricted = skillContent
|
|
49
|
+
? /^agents?\s*:/im.test(skillContent)
|
|
50
|
+
: false;
|
|
51
|
+
|
|
52
|
+
if (!isRestricted) {
|
|
53
|
+
skills.push(skillName);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (skillContent && skillContent.includes(agentId)) {
|
|
58
|
+
skills.push(skillName);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return skills;
|
|
63
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warning level thresholds mirroring context-monitor.mjs classifyContextThreshold().
|
|
3
|
+
* Reproduced here to avoid cross-module dependency.
|
|
4
|
+
*/
|
|
5
|
+
const WARNING_LEVELS = Object.freeze({
|
|
6
|
+
critical: 90,
|
|
7
|
+
warn: 80,
|
|
8
|
+
info: 60,
|
|
9
|
+
ok: 0,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Clamps a percentage value to [0, 100].
|
|
14
|
+
* @param {number} value
|
|
15
|
+
* @returns {number}
|
|
16
|
+
*/
|
|
17
|
+
function clampPercent(value) {
|
|
18
|
+
const n = Number(value);
|
|
19
|
+
if (!Number.isFinite(n)) return 0;
|
|
20
|
+
return Math.min(100, Math.max(0, n));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Classifies a usage percentage into a warning level.
|
|
25
|
+
* Mirrors context-monitor.mjs classifyContextThreshold().
|
|
26
|
+
* @param {number} percent
|
|
27
|
+
* @returns {{ level: string, message: string }}
|
|
28
|
+
*/
|
|
29
|
+
function classifyLevel(percent) {
|
|
30
|
+
const p = clampPercent(percent);
|
|
31
|
+
if (p >= WARNING_LEVELS.critical) return { level: "critical", message: "에이전트 분할 또는 세션 교체 권장" };
|
|
32
|
+
if (p >= WARNING_LEVELS.warn) return { level: "warn", message: "압축 권장" };
|
|
33
|
+
if (p >= WARNING_LEVELS.info) return { level: "info", message: "컨텍스트 절반 이상 사용" };
|
|
34
|
+
return { level: "ok", message: "" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Creates a per-agent token budget manager.
|
|
39
|
+
* @returns {object} Budget API
|
|
40
|
+
*/
|
|
41
|
+
export function createMeshBudget() {
|
|
42
|
+
// Map<agentId, { allocated: number, consumed: number }>
|
|
43
|
+
const budgets = new Map();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Allocates a token budget to an agent.
|
|
47
|
+
* @param {string} agentId
|
|
48
|
+
* @param {number} tokenLimit
|
|
49
|
+
*/
|
|
50
|
+
function allocate(agentId, tokenLimit) {
|
|
51
|
+
if (!agentId || typeof agentId !== "string") {
|
|
52
|
+
throw new TypeError("agentId must be a non-empty string");
|
|
53
|
+
}
|
|
54
|
+
const limit = Math.max(0, Math.round(Number(tokenLimit) || 0));
|
|
55
|
+
const existing = budgets.get(agentId);
|
|
56
|
+
budgets.set(agentId, {
|
|
57
|
+
allocated: limit,
|
|
58
|
+
consumed: existing?.consumed ?? 0,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Records token consumption for an agent.
|
|
64
|
+
* @param {string} agentId
|
|
65
|
+
* @param {number} tokens
|
|
66
|
+
* @returns {{ remaining: number, percent: number, level: string }}
|
|
67
|
+
*/
|
|
68
|
+
function consume(agentId, tokens) {
|
|
69
|
+
const budget = budgets.get(agentId);
|
|
70
|
+
if (!budget) {
|
|
71
|
+
throw new Error(`No budget allocated for agent: ${agentId}`);
|
|
72
|
+
}
|
|
73
|
+
const amount = Math.max(0, Math.round(Number(tokens) || 0));
|
|
74
|
+
const updated = {
|
|
75
|
+
allocated: budget.allocated,
|
|
76
|
+
consumed: budget.consumed + amount,
|
|
77
|
+
};
|
|
78
|
+
budgets.set(agentId, updated);
|
|
79
|
+
|
|
80
|
+
const remaining = Math.max(0, updated.allocated - updated.consumed);
|
|
81
|
+
const percent = updated.allocated > 0
|
|
82
|
+
? clampPercent((updated.consumed / updated.allocated) * 100)
|
|
83
|
+
: 100;
|
|
84
|
+
const { level } = classifyLevel(percent);
|
|
85
|
+
return { remaining, percent, level };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns the budget status for an agent.
|
|
90
|
+
* @param {string} agentId
|
|
91
|
+
* @returns {{ allocated: number, consumed: number, remaining: number, level: string }}
|
|
92
|
+
*/
|
|
93
|
+
function getStatus(agentId) {
|
|
94
|
+
const budget = budgets.get(agentId);
|
|
95
|
+
if (!budget) {
|
|
96
|
+
return { allocated: 0, consumed: 0, remaining: 0, level: "ok" };
|
|
97
|
+
}
|
|
98
|
+
const remaining = Math.max(0, budget.allocated - budget.consumed);
|
|
99
|
+
const percent = budget.allocated > 0
|
|
100
|
+
? clampPercent((budget.consumed / budget.allocated) * 100)
|
|
101
|
+
: 0;
|
|
102
|
+
const { level } = classifyLevel(percent);
|
|
103
|
+
return { allocated: budget.allocated, consumed: budget.consumed, remaining, level };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resets consumed tokens for all agents (keeps allocations).
|
|
108
|
+
*/
|
|
109
|
+
function resetAll() {
|
|
110
|
+
for (const [agentId, budget] of budgets) {
|
|
111
|
+
budgets.set(agentId, { allocated: budget.allocated, consumed: 0 });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Returns a snapshot of all current allocations.
|
|
117
|
+
* @returns {Map<string, { allocated: number, consumed: number }>}
|
|
118
|
+
*/
|
|
119
|
+
function listAllocations() {
|
|
120
|
+
const snap = new Map();
|
|
121
|
+
for (const [id, b] of budgets) {
|
|
122
|
+
snap.set(id, Object.freeze({ ...b }));
|
|
123
|
+
}
|
|
124
|
+
return snap;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { allocate, consume, getStatus, resetAll, listAllocations };
|
|
128
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const DEFAULT_INTERVAL_MS = 30_000;
|
|
2
|
+
const DEFAULT_THRESHOLD_MS = 60_000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a heartbeat monitor that tracks agent liveness.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} registry - A mesh-registry instance
|
|
8
|
+
* @param {object} [opts]
|
|
9
|
+
* @param {number} [opts.intervalMs=30000] - Scan interval
|
|
10
|
+
* @param {number} [opts.thresholdMs=60000] - Stale threshold
|
|
11
|
+
* @param {function} [opts.onStale] - Called with agentId when stale detected
|
|
12
|
+
* @returns {object} HeartbeatMonitor API
|
|
13
|
+
*/
|
|
14
|
+
export function createHeartbeatMonitor(registry, opts = {}) {
|
|
15
|
+
const {
|
|
16
|
+
intervalMs = DEFAULT_INTERVAL_MS,
|
|
17
|
+
thresholdMs = DEFAULT_THRESHOLD_MS,
|
|
18
|
+
onStale,
|
|
19
|
+
} = opts;
|
|
20
|
+
|
|
21
|
+
/** @type {Map<string, number>} agentId → last heartbeat timestamp */
|
|
22
|
+
const heartbeats = new Map();
|
|
23
|
+
let timer = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Records a heartbeat for an agent.
|
|
27
|
+
* @param {string} agentId
|
|
28
|
+
*/
|
|
29
|
+
function recordHeartbeat(agentId) {
|
|
30
|
+
if (!agentId || typeof agentId !== "string") {
|
|
31
|
+
throw new TypeError("agentId must be a non-empty string");
|
|
32
|
+
}
|
|
33
|
+
heartbeats.set(agentId, Date.now());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns agent IDs whose last heartbeat exceeds the threshold.
|
|
38
|
+
* Only considers agents currently registered in the registry.
|
|
39
|
+
*
|
|
40
|
+
* @param {number} [customThresholdMs] - Override default threshold
|
|
41
|
+
* @returns {string[]}
|
|
42
|
+
*/
|
|
43
|
+
function getStaleAgents(customThresholdMs) {
|
|
44
|
+
const threshold = customThresholdMs ?? thresholdMs;
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const registered = registry.listAll();
|
|
47
|
+
const stale = [];
|
|
48
|
+
|
|
49
|
+
for (const agent of registered) {
|
|
50
|
+
const lastBeat = heartbeats.get(agent.agentId);
|
|
51
|
+
if (lastBeat === undefined || (now - lastBeat) >= threshold) {
|
|
52
|
+
stale.push(agent.agentId);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return stale;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Runs a single scan: finds stale agents and invokes onStale callback.
|
|
60
|
+
*/
|
|
61
|
+
function scan() {
|
|
62
|
+
const stale = getStaleAgents();
|
|
63
|
+
if (typeof onStale === "function") {
|
|
64
|
+
for (const agentId of stale) {
|
|
65
|
+
onStale(agentId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Starts periodic heartbeat scanning.
|
|
72
|
+
* @param {number} [customIntervalMs]
|
|
73
|
+
*/
|
|
74
|
+
function start(customIntervalMs) {
|
|
75
|
+
stop();
|
|
76
|
+
const interval = customIntervalMs ?? intervalMs;
|
|
77
|
+
timer = setInterval(scan, interval);
|
|
78
|
+
timer.unref?.();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Stops periodic scanning.
|
|
83
|
+
*/
|
|
84
|
+
function stop() {
|
|
85
|
+
if (timer !== null) {
|
|
86
|
+
clearInterval(timer);
|
|
87
|
+
timer = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Removes heartbeat record for an agent.
|
|
93
|
+
* @param {string} agentId
|
|
94
|
+
*/
|
|
95
|
+
function remove(agentId) {
|
|
96
|
+
heartbeats.delete(agentId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { recordHeartbeat, getStaleAgents, start, stop, scan, remove };
|
|
100
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const MSG_TYPES = Object.freeze({
|
|
4
|
+
REQUEST: "request",
|
|
5
|
+
RESPONSE: "response",
|
|
6
|
+
EVENT: "event",
|
|
7
|
+
HEARTBEAT: "heartbeat",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const VALID_TYPES = new Set(Object.values(MSG_TYPES));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates an immutable mesh message.
|
|
14
|
+
* @param {string} type - One of MSG_TYPES values
|
|
15
|
+
* @param {string} from - Sender agent ID
|
|
16
|
+
* @param {string} to - Recipient agent ID (or "*" for broadcast)
|
|
17
|
+
* @param {unknown} payload - Message payload
|
|
18
|
+
* @returns {Readonly<object>}
|
|
19
|
+
*/
|
|
20
|
+
export function createMessage(type, from, to, payload = null) {
|
|
21
|
+
if (!VALID_TYPES.has(type)) {
|
|
22
|
+
throw new TypeError(`Invalid message type: ${type}`);
|
|
23
|
+
}
|
|
24
|
+
if (!from || typeof from !== "string") {
|
|
25
|
+
throw new TypeError("from must be a non-empty string");
|
|
26
|
+
}
|
|
27
|
+
if (!to || typeof to !== "string") {
|
|
28
|
+
throw new TypeError("to must be a non-empty string");
|
|
29
|
+
}
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
type,
|
|
32
|
+
from,
|
|
33
|
+
to,
|
|
34
|
+
payload,
|
|
35
|
+
timestamp: new Date().toISOString(),
|
|
36
|
+
correlationId: randomUUID(),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Serializes a message to a JSON string.
|
|
42
|
+
* @param {object} message
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function serialize(message) {
|
|
46
|
+
return JSON.stringify(message);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Deserializes a JSON string to a message object.
|
|
51
|
+
* @param {string} raw
|
|
52
|
+
* @returns {object}
|
|
53
|
+
*/
|
|
54
|
+
export function deserialize(raw) {
|
|
55
|
+
if (typeof raw !== "string") {
|
|
56
|
+
throw new TypeError("raw must be a string");
|
|
57
|
+
}
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new SyntaxError(`Failed to parse message: ${raw}`);
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validates a message object.
|
|
69
|
+
* @param {unknown} message
|
|
70
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
71
|
+
*/
|
|
72
|
+
export function validate(message) {
|
|
73
|
+
const errors = [];
|
|
74
|
+
|
|
75
|
+
if (!message || typeof message !== "object") {
|
|
76
|
+
return { valid: false, errors: ["message must be an object"] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!VALID_TYPES.has(message.type)) {
|
|
80
|
+
errors.push(`Invalid type: ${message.type}`);
|
|
81
|
+
}
|
|
82
|
+
if (!message.from || typeof message.from !== "string") {
|
|
83
|
+
errors.push("from must be a non-empty string");
|
|
84
|
+
}
|
|
85
|
+
if (!message.to || typeof message.to !== "string") {
|
|
86
|
+
errors.push("to must be a non-empty string");
|
|
87
|
+
}
|
|
88
|
+
if (!message.timestamp || typeof message.timestamp !== "string") {
|
|
89
|
+
errors.push("timestamp must be a non-empty string");
|
|
90
|
+
}
|
|
91
|
+
if (!message.correlationId || typeof message.correlationId !== "string") {
|
|
92
|
+
errors.push("correlationId must be a non-empty string");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { valid: errors.length === 0, errors };
|
|
96
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const DEFAULT_MAX_QUEUE_SIZE = 100;
|
|
2
|
+
const DEFAULT_TTL_MS = 0; // 0 = no expiry
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a per-agent message queue with TTL and size limits.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} [opts]
|
|
8
|
+
* @param {number} [opts.maxQueueSize=100] - Max messages per agent queue
|
|
9
|
+
* @param {number} [opts.ttlMs=0] - Message TTL in ms (0 = no expiry)
|
|
10
|
+
* @returns {object} Queue API
|
|
11
|
+
*/
|
|
12
|
+
export function createMessageQueue(opts = {}) {
|
|
13
|
+
const {
|
|
14
|
+
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
|
15
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
16
|
+
} = opts;
|
|
17
|
+
|
|
18
|
+
/** @type {Map<string, Array<{ message: object, enqueuedAt: number }>>} */
|
|
19
|
+
const queues = new Map();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns the queue array for an agent (creates if absent).
|
|
23
|
+
* @param {string} agentId
|
|
24
|
+
* @returns {Array}
|
|
25
|
+
*/
|
|
26
|
+
function getQueue(agentId) {
|
|
27
|
+
let q = queues.get(agentId);
|
|
28
|
+
if (!q) {
|
|
29
|
+
q = [];
|
|
30
|
+
queues.set(agentId, q);
|
|
31
|
+
}
|
|
32
|
+
return q;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Removes expired messages from the front of a queue.
|
|
37
|
+
* @param {Array} q
|
|
38
|
+
* @param {number} now
|
|
39
|
+
*/
|
|
40
|
+
function purgeExpired(q, now) {
|
|
41
|
+
if (ttlMs <= 0) return;
|
|
42
|
+
while (q.length > 0 && (now - q[0].enqueuedAt) > ttlMs) {
|
|
43
|
+
q.shift();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Adds a message to the target agent's queue.
|
|
49
|
+
* If queue exceeds maxQueueSize, the oldest message is dropped.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} agentId - Target agent
|
|
52
|
+
* @param {object} message - Mesh message
|
|
53
|
+
* @returns {{ queued: boolean, dropped: boolean }}
|
|
54
|
+
*/
|
|
55
|
+
function enqueue(agentId, message) {
|
|
56
|
+
if (!agentId || typeof agentId !== "string") {
|
|
57
|
+
throw new TypeError("agentId must be a non-empty string");
|
|
58
|
+
}
|
|
59
|
+
const q = getQueue(agentId);
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
|
|
62
|
+
purgeExpired(q, now);
|
|
63
|
+
|
|
64
|
+
let dropped = false;
|
|
65
|
+
if (q.length >= maxQueueSize) {
|
|
66
|
+
q.shift();
|
|
67
|
+
dropped = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
q.push({ message, enqueuedAt: now });
|
|
71
|
+
return { queued: true, dropped };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Removes and returns the next message for an agent.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} agentId
|
|
78
|
+
* @returns {object | null} The message, or null if queue is empty
|
|
79
|
+
*/
|
|
80
|
+
function dequeue(agentId) {
|
|
81
|
+
const q = queues.get(agentId);
|
|
82
|
+
if (!q || q.length === 0) return null;
|
|
83
|
+
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
purgeExpired(q, now);
|
|
86
|
+
|
|
87
|
+
if (q.length === 0) return null;
|
|
88
|
+
return q.shift().message;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns the next message without removing it.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} agentId
|
|
95
|
+
* @returns {object | null}
|
|
96
|
+
*/
|
|
97
|
+
function peek(agentId) {
|
|
98
|
+
const q = queues.get(agentId);
|
|
99
|
+
if (!q || q.length === 0) return null;
|
|
100
|
+
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
purgeExpired(q, now);
|
|
103
|
+
|
|
104
|
+
if (q.length === 0) return null;
|
|
105
|
+
return q[0].message;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Returns the number of (non-expired) messages in an agent's queue.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} agentId
|
|
112
|
+
* @returns {number}
|
|
113
|
+
*/
|
|
114
|
+
function size(agentId) {
|
|
115
|
+
const q = queues.get(agentId);
|
|
116
|
+
if (!q) return 0;
|
|
117
|
+
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
purgeExpired(q, now);
|
|
120
|
+
|
|
121
|
+
return q.length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Drains all messages for an agent.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} agentId
|
|
128
|
+
* @returns {object[]} Array of messages
|
|
129
|
+
*/
|
|
130
|
+
function drain(agentId) {
|
|
131
|
+
const q = queues.get(agentId);
|
|
132
|
+
if (!q || q.length === 0) return [];
|
|
133
|
+
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
purgeExpired(q, now);
|
|
136
|
+
|
|
137
|
+
const messages = q.map((entry) => entry.message);
|
|
138
|
+
q.length = 0;
|
|
139
|
+
return messages;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Removes an agent's queue entirely.
|
|
144
|
+
* @param {string} agentId
|
|
145
|
+
*/
|
|
146
|
+
function clear(agentId) {
|
|
147
|
+
queues.delete(agentId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Returns total message count across all agent queues.
|
|
152
|
+
* @returns {number}
|
|
153
|
+
*/
|
|
154
|
+
function totalSize() {
|
|
155
|
+
let total = 0;
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
for (const [, q] of queues) {
|
|
158
|
+
purgeExpired(q, now);
|
|
159
|
+
total += q.length;
|
|
160
|
+
}
|
|
161
|
+
return total;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { enqueue, dequeue, peek, size, drain, clear, totalSize };
|
|
165
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates an agent registry for the mesh network.
|
|
3
|
+
* Agents register with capabilities; registry enables discovery.
|
|
4
|
+
* @returns {object} Registry API
|
|
5
|
+
*/
|
|
6
|
+
export function createRegistry() {
|
|
7
|
+
// Map<agentId, AgentInfo>
|
|
8
|
+
const agents = new Map();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Registers an agent with the registry.
|
|
12
|
+
* @param {string} agentId
|
|
13
|
+
* @param {string[]} capabilities
|
|
14
|
+
*/
|
|
15
|
+
function register(agentId, capabilities = []) {
|
|
16
|
+
if (!agentId || typeof agentId !== "string") {
|
|
17
|
+
throw new TypeError("agentId must be a non-empty string");
|
|
18
|
+
}
|
|
19
|
+
if (!Array.isArray(capabilities)) {
|
|
20
|
+
throw new TypeError("capabilities must be an array");
|
|
21
|
+
}
|
|
22
|
+
const info = Object.freeze({
|
|
23
|
+
agentId,
|
|
24
|
+
capabilities: Object.freeze([...capabilities]),
|
|
25
|
+
registeredAt: new Date().toISOString(),
|
|
26
|
+
});
|
|
27
|
+
agents.set(agentId, info);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Unregisters an agent from the registry.
|
|
32
|
+
* @param {string} agentId
|
|
33
|
+
*/
|
|
34
|
+
function unregister(agentId) {
|
|
35
|
+
agents.delete(agentId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Discovers agents that have a specific capability.
|
|
40
|
+
* @param {string} capability
|
|
41
|
+
* @returns {string[]} Array of agentIds
|
|
42
|
+
*/
|
|
43
|
+
function discover(capability) {
|
|
44
|
+
const result = [];
|
|
45
|
+
for (const [agentId, info] of agents) {
|
|
46
|
+
if (info.capabilities.includes(capability)) {
|
|
47
|
+
result.push(agentId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Gets agent info by ID.
|
|
55
|
+
* @param {string} agentId
|
|
56
|
+
* @returns {object | null}
|
|
57
|
+
*/
|
|
58
|
+
function getAgent(agentId) {
|
|
59
|
+
return agents.get(agentId) ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Lists all registered agents.
|
|
64
|
+
* @returns {object[]}
|
|
65
|
+
*/
|
|
66
|
+
function listAll() {
|
|
67
|
+
return [...agents.values()];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Clears all registered agents.
|
|
72
|
+
*/
|
|
73
|
+
function clear() {
|
|
74
|
+
agents.clear();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { register, unregister, discover, getAgent, listAll, clear };
|
|
78
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { validate } from "./mesh-protocol.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Routes a mesh message to target agent(s) based on the `to` field.
|
|
5
|
+
*
|
|
6
|
+
* Addressing modes:
|
|
7
|
+
* - "agent-id" → direct delivery (registry lookup)
|
|
8
|
+
* - "*" → broadcast to all registered agents
|
|
9
|
+
* - "capability:X" → discover agents with capability X
|
|
10
|
+
*
|
|
11
|
+
* @param {object} message - A mesh-protocol message
|
|
12
|
+
* @param {object} registry - A mesh-registry instance
|
|
13
|
+
* @returns {{ routed: boolean, targets?: string[], reason?: string }}
|
|
14
|
+
*/
|
|
15
|
+
export function routeMessage(message, registry) {
|
|
16
|
+
const { valid, errors } = validate(message);
|
|
17
|
+
if (!valid) {
|
|
18
|
+
return { routed: false, reason: `invalid message: ${errors.join(", ")}` };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { to, from } = message;
|
|
22
|
+
|
|
23
|
+
// Broadcast
|
|
24
|
+
if (to === "*") {
|
|
25
|
+
const all = registry.listAll();
|
|
26
|
+
const targets = all
|
|
27
|
+
.map((a) => a.agentId)
|
|
28
|
+
.filter((id) => id !== from);
|
|
29
|
+
if (targets.length === 0) {
|
|
30
|
+
return { routed: false, reason: "broadcast: no agents registered" };
|
|
31
|
+
}
|
|
32
|
+
return { routed: true, targets };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Capability-based routing
|
|
36
|
+
if (to.startsWith("capability:")) {
|
|
37
|
+
const capability = to.slice("capability:".length);
|
|
38
|
+
if (!capability) {
|
|
39
|
+
return { routed: false, reason: "capability: empty capability name" };
|
|
40
|
+
}
|
|
41
|
+
const targets = registry.discover(capability);
|
|
42
|
+
if (targets.length === 0) {
|
|
43
|
+
return { routed: false, reason: `capability: no agents with "${capability}"` };
|
|
44
|
+
}
|
|
45
|
+
return { routed: true, targets };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Direct addressing
|
|
49
|
+
const agent = registry.getAgent(to);
|
|
50
|
+
if (!agent) {
|
|
51
|
+
return { routed: false, reason: `agent not found: "${to}"` };
|
|
52
|
+
}
|
|
53
|
+
return { routed: true, targets: [to] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Routes a message and collects dead-letter info when delivery fails.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} message
|
|
60
|
+
* @param {object} registry
|
|
61
|
+
* @returns {{ routed: boolean, targets?: string[], deadLetter?: object }}
|
|
62
|
+
*/
|
|
63
|
+
export function routeOrDeadLetter(message, registry) {
|
|
64
|
+
const result = routeMessage(message, registry);
|
|
65
|
+
if (!result.routed) {
|
|
66
|
+
return {
|
|
67
|
+
...result,
|
|
68
|
+
deadLetter: {
|
|
69
|
+
originalMessage: message,
|
|
70
|
+
reason: result.reason,
|
|
71
|
+
timestamp: new Date().toISOString(),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triflux",
|
|
3
|
-
"version": "10.3.
|
|
3
|
+
"version": "10.3.3",
|
|
4
4
|
"description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"scripts",
|
|
25
25
|
"hooks",
|
|
26
26
|
"hud",
|
|
27
|
+
"mesh",
|
|
27
28
|
".claude-plugin",
|
|
28
29
|
"README.md",
|
|
29
30
|
"README.ko.md",
|
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { TFX_START, OMC_END, writeSection } from "./lib/claudemd-scanner.mjs";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// 1. git root (가장 신뢰)
|
|
10
|
-
try {
|
|
11
|
-
return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
|
|
12
|
-
} catch { /* not a git repo */ }
|
|
13
|
-
// 2. cwd fallback (npm 사용자 등)
|
|
14
|
-
return process.cwd();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const PROJECT_ROOT = resolveProjectRoot();
|
|
18
|
-
const PROJECT_CLAUDE_MD_PATH = join(PROJECT_ROOT, "CLAUDE.md");
|
|
7
|
+
const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
8
|
+
const GLOBAL_CLAUDE_MD_PATH = join(homedir(), ".claude", "CLAUDE.md");
|
|
9
|
+
const PKG_CLAUDE_MD_PATH = join(PKG_ROOT, "CLAUDE.md");
|
|
19
10
|
const ROUTING_TAG_OPEN = "<routing>";
|
|
20
11
|
const ROUTING_TAG_CLOSE = "</routing>";
|
|
21
12
|
// Legacy heading fallback
|
|
@@ -84,18 +75,14 @@ function toSkippedResult(path, reason) {
|
|
|
84
75
|
}
|
|
85
76
|
|
|
86
77
|
export function getLatestRoutingTable() {
|
|
87
|
-
|
|
88
|
-
|
|
78
|
+
// 1차: 사용자 글로벌 ~/.claude/CLAUDE.md (어디서든 접근 가능한 공통 경로)
|
|
79
|
+
for (const candidate of [GLOBAL_CLAUDE_MD_PATH, PKG_CLAUDE_MD_PATH]) {
|
|
80
|
+
if (!existsSync(candidate)) continue;
|
|
81
|
+
const section = findRoutingSection(readFileSync(candidate, "utf8"));
|
|
82
|
+
if (section.found) return section.section.trim();
|
|
89
83
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const section = findRoutingSection(projectMarkdown);
|
|
93
|
-
|
|
94
|
-
if (!section.found) {
|
|
95
|
-
throw new Error(`routing section not found in: ${PROJECT_CLAUDE_MD_PATH}`);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return section.section.trim();
|
|
84
|
+
// 2차 fallback: 패키지 CLAUDE.md도 없으면 에러
|
|
85
|
+
throw new Error(`routing section not found in: ${GLOBAL_CLAUDE_MD_PATH} or ${PKG_CLAUDE_MD_PATH}`);
|
|
99
86
|
}
|
|
100
87
|
|
|
101
88
|
export function ensureTfxSection(claudeMdPath, routingTable) {
|
package/scripts/setup.mjs
CHANGED
|
@@ -968,6 +968,29 @@ if (settingsChanged) {
|
|
|
968
968
|
}
|
|
969
969
|
}
|
|
970
970
|
|
|
971
|
+
// ── HUD 캐시 pre-warm (백그라운드) ──
|
|
972
|
+
|
|
973
|
+
const preWarmHudPath = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
|
|
974
|
+
if (existsSync(preWarmHudPath)) {
|
|
975
|
+
const refreshFlags = [
|
|
976
|
+
["--refresh-claude-usage"],
|
|
977
|
+
["--refresh-codex-rate-limits"],
|
|
978
|
+
["--refresh-gemini-quota", "--account", "gemini-main"],
|
|
979
|
+
["--refresh-gemini-session"],
|
|
980
|
+
];
|
|
981
|
+
for (const args of refreshFlags) {
|
|
982
|
+
try {
|
|
983
|
+
const child = spawn(process.execPath, [preWarmHudPath, ...args], {
|
|
984
|
+
detached: true,
|
|
985
|
+
stdio: "ignore",
|
|
986
|
+
windowsHide: true,
|
|
987
|
+
});
|
|
988
|
+
child.unref();
|
|
989
|
+
} catch { /* pre-warm 실패 무시 */ }
|
|
990
|
+
}
|
|
991
|
+
console.log(" \x1b[32m✓\x1b[0m HUD cache pre-warm (background)");
|
|
992
|
+
}
|
|
993
|
+
|
|
971
994
|
// ── Stale PID 파일 정리 (hub 좀비 방지) ──
|
|
972
995
|
|
|
973
996
|
const HUB_PID_FILE = join(CLAUDE_DIR, "cache", "tfx-hub", "hub.pid");
|