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.
@@ -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": "9.7.14",
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": "9.7.14"
33
+ "version": "10.3.2"
34
34
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.0.0",
3
+ "version": "10.3.2",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "author": {
6
6
  "name": "tellang"
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
- // wcwidth-aware truncate: wide char 경계에서 자름
253
- export function truncate(str, maxLen) {
254
- const plain = stripAnsi(str);
255
- const w = wcswidth(plain);
256
- if (w <= maxLen) return str;
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
- for (const char of plain) {
261
- const cw = charWidth(char.codePointAt(0));
262
- if (acc + cw > maxLen - 1) break;
263
- acc += cw;
264
- i += char.length;
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
- return plain.slice(0, i) + "…";
281
+
282
+ return { result, visWidth, hasAnsi };
267
283
  }
268
284
 
269
- // wcwidth-aware clip: 정확히 width 셀에 맞게 자르고 패딩 (wide char 경계 보정)
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 plain = stripAnsi(str);
272
- let acc = 0;
273
- let i = 0;
274
- for (const char of plain) {
275
- const cw = charWidth(char.codePointAt(0));
276
- if (acc + cw > width) {
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 색상 상수 ──
@@ -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
- getConductorRegistry().register(frozenApi);
800
+ ensureConductorRegistry();
801
801
  return frozenApi;
802
802
  }
@@ -265,12 +265,12 @@ export function createLiteDashboard(opts = {}) {
265
265
  return;
266
266
  }
267
267
  if (key === "\r" || key === "\n") {
268
- if (typeof onOpenSelectedWorker !== "function") {
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 statusBar = truncate(
1152
- color(` 세션 종료됨 아무 키나 누르면 닫힘`, MOCHA.subtext),
1153
- totalCols,
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
- const toErase = prevLen > newRows.length
1169
- ? Array.from({ length: prevLen - newRows.length }, (_, i) => newRows.length + i)
1170
- : [];
1171
-
1172
+ let buf = "";
1172
1173
  for (const i of dirty) {
1173
- write(moveTo(i + 1, 1) + clearLine + (newRows[i] || ""));
1174
+ buf += moveTo(i + 1, 1) + clearLine + (newRows[i] || "");
1174
1175
  }
1175
- for (const i of toErase) {
1176
- write(moveTo(i + 1, 1) + clearLine);
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 = 15 * 1000; // 15
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 = 15 * 1000; // 15
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;
@@ -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,
@@ -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.1",
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
- import { execFileSync } from "node:child_process";
7
-
8
- function resolveProjectRoot() {
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
- if (!existsSync(PROJECT_CLAUDE_MD_PATH)) {
88
- throw new Error(`project CLAUDE.md not found: ${PROJECT_CLAUDE_MD_PATH}`);
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
- const projectMarkdown = readFileSync(PROJECT_CLAUDE_MD_PATH, "utf8");
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");