triflux 10.3.2 → 10.3.4

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.
Files changed (65) hide show
  1. package/.claude-plugin/plugin.json +22 -22
  2. package/LICENSE +21 -21
  3. package/README.ko.md +16 -0
  4. package/README.md +8 -0
  5. package/hooks/hook-registry.json +256 -256
  6. package/hub/adaptive-inject.mjs +1 -1
  7. package/hub/assign-callbacks.mjs +120 -120
  8. package/hub/delegator/index.mjs +14 -14
  9. package/hub/delegator/tool-definitions.mjs +35 -35
  10. package/hub/hitl.mjs +143 -143
  11. package/hub/lib/path-utils.mjs +167 -0
  12. package/hub/router.mjs +791 -791
  13. package/hub/session-fingerprint.mjs +1 -1
  14. package/hub/team/cli/commands/attach.mjs +37 -37
  15. package/hub/team/cli/commands/debug.mjs +74 -74
  16. package/hub/team/cli/commands/focus.mjs +53 -53
  17. package/hub/team/cli/commands/list.mjs +24 -24
  18. package/hub/team/cli/commands/start/start-in-process.mjs +40 -40
  19. package/hub/team/cli/commands/start/start-mux.mjs +73 -73
  20. package/hub/team/cli/commands/start/start-wt.mjs +69 -69
  21. package/hub/team/cli/commands/tasks.mjs +13 -13
  22. package/hub/team/cli/render.mjs +30 -30
  23. package/hub/team/cli/services/attach-fallback.mjs +54 -54
  24. package/hub/team/cli/services/member-selector.mjs +30 -30
  25. package/hub/team/cli/services/native-control.mjs +116 -116
  26. package/hub/team/cli/services/task-model.mjs +30 -30
  27. package/hub/team/notify.mjs +1 -1
  28. package/hub/team/orchestrator.mjs +161 -161
  29. package/hub/team/runtime-strategy.mjs +74 -0
  30. package/hub/team/session.mjs +611 -611
  31. package/hub/team/shared.mjs +13 -13
  32. package/hub/team/worktree-lifecycle.mjs +61 -2
  33. package/hub/tray.mjs +368 -368
  34. package/hub/workers/codex-mcp.mjs +507 -507
  35. package/hub/workers/factory.mjs +21 -21
  36. package/hud/hud-qos-status.mjs +17 -3
  37. package/hud/mission-board.mjs +53 -0
  38. package/hud/providers/claude.mjs +95 -22
  39. package/hud/renderers.mjs +39 -5
  40. package/mesh/index.mjs +63 -0
  41. package/mesh/mesh-budget.mjs +128 -0
  42. package/mesh/mesh-heartbeat.mjs +100 -0
  43. package/mesh/mesh-protocol.mjs +96 -0
  44. package/mesh/mesh-queue.mjs +165 -0
  45. package/mesh/mesh-registry.mjs +78 -0
  46. package/mesh/mesh-router.mjs +76 -0
  47. package/package.json +2 -1
  48. package/scripts/completions/tfx.bash +47 -47
  49. package/scripts/completions/tfx.fish +44 -44
  50. package/scripts/completions/tfx.zsh +83 -83
  51. package/scripts/demo.mjs +169 -0
  52. package/scripts/headless-guard.mjs +16 -4
  53. package/scripts/hub-ensure.mjs +120 -120
  54. package/scripts/keyword-detector.mjs +272 -272
  55. package/scripts/keyword-rules-expander.mjs +521 -521
  56. package/scripts/lib/mcp-server-catalog.mjs +118 -118
  57. package/scripts/lib/skill-state.mjs +220 -0
  58. package/scripts/notion-read.mjs +553 -553
  59. package/scripts/test-tfx-route-no-claude-native.mjs +57 -57
  60. package/scripts/tfx-batch-stats.mjs +96 -96
  61. package/skills/.omc/state/agent-replay-8f0e10a9-9693-4410-96f5-a6b07e8ed995.jsonl +0 -1
  62. package/skills/.omc/state/idle-notif-cooldown.json +0 -3
  63. package/skills/.omc/state/last-tool-error.json +0 -7
  64. package/skills/.omc/state/subagent-tracking.json +0 -7
  65. package/skills/tfx-remote-spawn/references/hosts.json +0 -16
@@ -1,21 +1,21 @@
1
- // hub/workers/factory.mjs — Worker 생성 팩토리
2
-
3
- import { GeminiWorker } from './gemini-worker.mjs';
4
- import { ClaudeWorker } from './claude-worker.mjs';
5
- import { CodexMcpWorker } from './codex-mcp.mjs';
6
- import { DelegatorMcpWorker } from './delegator-mcp.mjs';
7
-
8
- export function createWorker(type, opts = {}) {
9
- switch (type) {
10
- case 'gemini':
11
- return new GeminiWorker(opts);
12
- case 'claude':
13
- return new ClaudeWorker(opts);
14
- case 'codex':
15
- return new CodexMcpWorker(opts);
16
- case 'delegator':
17
- return new DelegatorMcpWorker(opts);
18
- default:
19
- throw new Error(`Unknown worker type: ${type}`);
20
- }
21
- }
1
+ // hub/workers/factory.mjs — Worker 생성 팩토리
2
+
3
+ import { GeminiWorker } from './gemini-worker.mjs';
4
+ import { ClaudeWorker } from './claude-worker.mjs';
5
+ import { CodexMcpWorker } from './codex-mcp.mjs';
6
+ import { DelegatorMcpWorker } from './delegator-mcp.mjs';
7
+
8
+ export function createWorker(type, opts = {}) {
9
+ switch (type) {
10
+ case 'gemini':
11
+ return new GeminiWorker(opts);
12
+ case 'claude':
13
+ return new ClaudeWorker(opts);
14
+ case 'codex':
15
+ return new CodexMcpWorker(opts);
16
+ case 'delegator':
17
+ return new DelegatorMcpWorker(opts);
18
+ default:
19
+ throw new Error(`Unknown worker type: ${type}`);
20
+ }
21
+ }
@@ -16,6 +16,7 @@ import {
16
16
  readJson, readStdinJson, getProviderAccountId, getCliArgValue,
17
17
  } from "./utils.mjs";
18
18
  import { selectTier } from "./terminal.mjs";
19
+ import { getMissionBoardState } from "./mission-board.mjs";
19
20
 
20
21
  // Claude provider
21
22
  import {
@@ -41,7 +42,7 @@ import {
41
42
  // Renderers
42
43
  import {
43
44
  getClaudeRows, getProviderRow, getTeamRow,
44
- renderAlignedRows, getMicroLine,
45
+ renderAlignedRows, getMicroLine, renderMissionBoard,
45
46
  readLatestBenchmarkDiff, formatTokenSummary,
46
47
  readTokenSavings, readSvAccumulator,
47
48
  } from "./renderers.mjs";
@@ -103,11 +104,15 @@ async function main() {
103
104
  // 실측 데이터 추출
104
105
  const stdin = await stdinPromise;
105
106
  const contextView = buildContextUsageView(stdin, contextSnapshot);
107
+ const claudeUsage = claudeUsageSnapshot.isStale
108
+ ? { ...(claudeUsageSnapshot.data || {}), stale: true }
109
+ : claudeUsageSnapshot.data;
106
110
  const codexEmail = getCodexEmail();
107
111
  const geminiEmail = getGeminiEmail();
108
112
  const codexBuckets = codexSnapshot.buckets;
109
113
  const geminiSession = geminiSessionSnapshot.session;
110
114
  const geminiQuota = geminiQuotaSnapshot.quota;
115
+ const missionBoardState = await getMissionBoardState();
111
116
 
112
117
  // 누적 절약 데이터 읽기
113
118
  const svSavings = readTokenSavings();
@@ -149,7 +154,7 @@ async function main() {
149
154
 
150
155
  // nano tier: 1줄 모드 (극소 폭 또는 알림 배너 대응)
151
156
  if (CURRENT_TIER === "nano") {
152
- const microLine = getMicroLine(contextView, claudeUsageSnapshot.data, codexBuckets,
157
+ const microLine = getMicroLine(contextView, claudeUsage, codexBuckets,
153
158
  geminiSession, geminiBucket, combinedSvPct);
154
159
  process.stdout.write(`\x1b[0m${microLine}\n`);
155
160
  return;
@@ -164,7 +169,7 @@ async function main() {
164
169
  };
165
170
 
166
171
  const rows = [
167
- ...getClaudeRows(CURRENT_TIER, contextView, claudeUsageSnapshot.data, combinedSvPct),
172
+ ...getClaudeRows(CURRENT_TIER, contextView, claudeUsage, combinedSvPct),
168
173
  getProviderRow(CURRENT_TIER, "codex", "x", codexWhite, qosProfile, accountsConfig, accountsState,
169
174
  codexQuotaData, codexEmail, codexSv, null),
170
175
  getProviderRow(CURRENT_TIER, "gemini", "g", geminiBlue, qosProfile, accountsConfig, accountsState,
@@ -175,6 +180,15 @@ async function main() {
175
180
  const teamRow = getTeamRow(CURRENT_TIER);
176
181
  if (teamRow) rows.push(teamRow);
177
182
 
183
+ const missionBoard = renderMissionBoard(missionBoardState);
184
+ if (missionBoard) {
185
+ rows.push({
186
+ prefix: bold(claudeOrange("\u25B2")),
187
+ left: missionBoard,
188
+ right: "",
189
+ });
190
+ }
191
+
178
192
  // 최근 벤치마크 diff → 토큰 요약 행 추가
179
193
  const latestDiff = readLatestBenchmarkDiff();
180
194
  if (latestDiff) {
@@ -0,0 +1,53 @@
1
+ // ============================================================================
2
+ // HUD Mission Board — .omc/state/sessions/ 기반 에이전트 진행률 집계
3
+ // ============================================================================
4
+ import { readdir, readFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ const SESSIONS_DIR = join(homedir(), ".omc", "state", "sessions");
9
+
10
+ /**
11
+ * .omc/state/sessions/ 디렉토리를 읽어 팀 상태를 반환한다.
12
+ * @returns {{ agents: Array<{name: string, status: string, progress: number}>, dagLevel: number, totalProgress: number } | null}
13
+ */
14
+ export async function getMissionBoardState(sessionsDir = SESSIONS_DIR) {
15
+ let entries;
16
+ try {
17
+ entries = await readdir(sessionsDir);
18
+ } catch {
19
+ return null;
20
+ }
21
+
22
+ const jsonFiles = entries.filter((f) => f.endsWith(".json"));
23
+ if (jsonFiles.length === 0) return null;
24
+
25
+ const agents = [];
26
+ for (const file of jsonFiles) {
27
+ let data;
28
+ try {
29
+ const raw = await readFile(join(sessionsDir, file), "utf8");
30
+ data = JSON.parse(raw);
31
+ } catch {
32
+ continue;
33
+ }
34
+
35
+ const name = data.name ?? file.replace(/\.json$/, "");
36
+ const status = data.status ?? "idle";
37
+ const progress = typeof data.progress === "number" ? data.progress : 0;
38
+ agents.push({ name, status, progress });
39
+ }
40
+
41
+ if (agents.length === 0) return null;
42
+
43
+ const totalProgress = agents.length > 0
44
+ ? Math.round(agents.reduce((sum, a) => sum + a.progress, 0) / agents.length)
45
+ : 0;
46
+
47
+ return {
48
+ agents,
49
+ // TODO: derive dagLevel from real mission dependency metadata instead of hardcoding 0.
50
+ dagLevel: 0,
51
+ totalProgress,
52
+ };
53
+ }
@@ -16,6 +16,16 @@ import {
16
16
  import { readJson, writeJsonSafe, clampPercent, advanceToNextCycle } from "../utils.mjs";
17
17
  import { readContextMonitorSnapshot } from "../context-monitor.mjs";
18
18
 
19
+ export const CLAUDE_USAGE_POLL_BASE_MS = 5_000;
20
+ export const CLAUDE_USAGE_POLL_JITTER_RATIO = 0.2;
21
+ export const CLAUDE_USAGE_RATE_LIMIT_BACKOFF_MS = [
22
+ CLAUDE_USAGE_POLL_BASE_MS,
23
+ 10_000,
24
+ 30_000,
25
+ 60_000,
26
+ 120_000,
27
+ ];
28
+
19
29
  // OMC 활성 여부에 따라 캐시 TTL 동적 결정
20
30
  function getClaudeUsageStaleMs() {
21
31
  return existsSync(OMC_PLUGIN_USAGE_CACHE_PATH)
@@ -23,6 +33,49 @@ function getClaudeUsageStaleMs() {
23
33
  : CLAUDE_USAGE_STALE_MS_SOLO;
24
34
  }
25
35
 
36
+ export function computeClaudeUsagePollState({
37
+ consecutive429s = 0,
38
+ outcome = "success",
39
+ random = Math.random,
40
+ jitterRatio = CLAUDE_USAGE_POLL_JITTER_RATIO,
41
+ } = {}) {
42
+ const current429s = Number.isFinite(consecutive429s) ? Math.max(0, consecutive429s) : 0;
43
+ const next429s = outcome === "rate_limit" ? current429s + 1 : 0;
44
+ const stepIndex = outcome === "rate_limit"
45
+ ? Math.min(next429s, CLAUDE_USAGE_RATE_LIMIT_BACKOFF_MS.length - 1)
46
+ : 0;
47
+ const baseDelayMs = CLAUDE_USAGE_RATE_LIMIT_BACKOFF_MS[stepIndex];
48
+ const sample = Number(random?.());
49
+ const normalized = Number.isFinite(sample) ? Math.min(1, Math.max(0, sample)) : 0.5;
50
+ const jitterFactor = 1 + ((normalized * 2) - 1) * jitterRatio;
51
+ return {
52
+ consecutive429s: next429s,
53
+ baseDelayMs,
54
+ delayMs: Math.max(1, Math.round(baseDelayMs * jitterFactor)),
55
+ };
56
+ }
57
+
58
+ function getSnapshotSchedule(cache) {
59
+ const timestamp = Number(cache?.timestamp);
60
+ const nextRefreshAt = Number(cache?.nextRefreshAt);
61
+ if (Number.isFinite(nextRefreshAt)) {
62
+ return {
63
+ nextRefreshAt,
64
+ shouldRefresh: Date.now() >= nextRefreshAt,
65
+ };
66
+ }
67
+
68
+ const ageMs = Number.isFinite(timestamp) ? Date.now() - timestamp : Number.MAX_SAFE_INTEGER;
69
+ const fallbackMs = cache?.error
70
+ ? (cache.errorType === "rate_limit" ? CLAUDE_USAGE_429_BACKOFF_MS : CLAUDE_USAGE_ERROR_BACKOFF_MS)
71
+ : getClaudeUsageStaleMs();
72
+
73
+ return {
74
+ nextRefreshAt: Number.isFinite(timestamp) ? timestamp + fallbackMs : null,
75
+ shouldRefresh: ageMs >= fallbackMs,
76
+ };
77
+ }
78
+
26
79
  export function readClaudeCredentials() {
27
80
  const data = readJson(CLAUDE_CREDENTIALS_PATH, null);
28
81
  if (!data) return null;
@@ -154,18 +207,21 @@ export function stripStaleResets(data) {
154
207
  export function readClaudeUsageSnapshot() {
155
208
  const cache = readJson(CLAUDE_USAGE_CACHE_PATH, null);
156
209
  const ts = Number(cache?.timestamp);
157
- const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
210
+ const schedule = getSnapshotSchedule(cache);
211
+ const staleBackoffActive = cache?.errorType === "rate_limit"
212
+ && Number.isFinite(schedule.nextRefreshAt)
213
+ && Date.now() < schedule.nextRefreshAt;
158
214
 
159
215
  // 1차: 자체 캐시에 유효 데이터가 있는 경우
160
216
  if (cache?.data) {
161
217
  // 에러 상태에서 보존된 stale 데이터 → backoff 존중하되 표시용 데이터 반환
162
218
  if (cache.error) {
163
- const backoffMs = cache.errorType === "rate_limit"
164
- ? CLAUDE_USAGE_429_BACKOFF_MS
165
- : CLAUDE_USAGE_ERROR_BACKOFF_MS;
166
- return { data: stripStaleResets(cache.data), shouldRefresh: ageMs >= backoffMs };
219
+ return {
220
+ data: stripStaleResets(cache.data),
221
+ shouldRefresh: schedule.shouldRefresh,
222
+ isStale: staleBackoffActive,
223
+ };
167
224
  }
168
- const isFresh = ageMs < getClaudeUsageStaleMs();
169
225
  // resets_at이 지난 윈도우의 percent를 0으로 보정 (stale 캐시 방지)
170
226
  const data = { ...cache.data };
171
227
  const now = Date.now();
@@ -175,24 +231,21 @@ export function readClaudeUsageSnapshot() {
175
231
  if (data.weeklyResetsAt && new Date(data.weeklyResetsAt).getTime() <= now) {
176
232
  data.weeklyPercent = 0;
177
233
  }
178
- return { data, shouldRefresh: !isFresh };
234
+ return { data, shouldRefresh: schedule.shouldRefresh, isStale: false };
179
235
  }
180
236
 
181
237
  // 2차: 에러 backoff — 최근 에러 시 재시도 억제 (무한 spawn 방지)
182
238
  if (cache?.error && Number.isFinite(ts)) {
183
- const backoffMs = cache.errorType === "rate_limit"
184
- ? CLAUDE_USAGE_429_BACKOFF_MS
185
- : CLAUDE_USAGE_ERROR_BACKOFF_MS;
186
- if (ageMs < backoffMs) {
239
+ if (!schedule.shouldRefresh) {
187
240
  const omcCache = readJson(OMC_PLUGIN_USAGE_CACHE_PATH, null);
188
241
  // OMC 캐시가 에러 이후 갱신되었으면 → 에러 캐시 덮어쓰고 그 데이터 사용
189
242
  if (omcCache?.data?.fiveHourPercent != null && omcCache.timestamp > ts) {
190
243
  writeClaudeUsageCache(omcCache.data);
191
- return { data: omcCache.data, shouldRefresh: false };
244
+ return { data: omcCache.data, shouldRefresh: false, isStale: false };
192
245
  }
193
246
  // stale OMC fallback 또는 null (--% 플레이스홀더 표시, 가짜 0% 방지)
194
247
  const staleData = omcCache?.data?.fiveHourPercent != null ? stripStaleResets(omcCache.data) : null;
195
- return { data: staleData, shouldRefresh: false };
248
+ return { data: staleData, shouldRefresh: false, isStale: staleBackoffActive };
196
249
  }
197
250
  }
198
251
 
@@ -203,23 +256,37 @@ export function readClaudeUsageSnapshot() {
203
256
  const omcAge = Number.isFinite(omcCache.timestamp) ? Date.now() - omcCache.timestamp : Number.MAX_SAFE_INTEGER;
204
257
  if (omcAge < OMC_CACHE_MAX_AGE_MS) {
205
258
  writeClaudeUsageCache(omcCache.data);
206
- return { data: omcCache.data, shouldRefresh: omcAge > getClaudeUsageStaleMs() };
259
+ return { data: omcCache.data, shouldRefresh: omcAge > getClaudeUsageStaleMs(), isStale: false };
207
260
  }
208
261
  // stale이어도 data: null보다는 오래된 데이터를 fallback으로 표시
209
- return { data: stripStaleResets(omcCache.data), shouldRefresh: true };
262
+ return { data: stripStaleResets(omcCache.data), shouldRefresh: true, isStale: false };
210
263
  }
211
264
 
212
265
  // 캐시/fallback 모두 없음: null 반환 → --% 플레이스홀더 + 리프레시 시도
213
- return { data: null, shouldRefresh: true };
266
+ return { data: null, shouldRefresh: true, isStale: false };
214
267
  }
215
268
 
216
- export function writeClaudeUsageCache(data, errorInfo = null) {
269
+ export function writeClaudeUsageCache(data, errorInfo = null, pollState = null) {
270
+ const state = pollState || (errorInfo
271
+ ? {
272
+ consecutive429s: errorInfo.type === "rate_limit" ? 1 : 0,
273
+ baseDelayMs: errorInfo.type === "rate_limit"
274
+ ? CLAUDE_USAGE_429_BACKOFF_MS
275
+ : CLAUDE_USAGE_ERROR_BACKOFF_MS,
276
+ delayMs: errorInfo.type === "rate_limit"
277
+ ? CLAUDE_USAGE_429_BACKOFF_MS
278
+ : CLAUDE_USAGE_ERROR_BACKOFF_MS,
279
+ }
280
+ : computeClaudeUsagePollState({ outcome: "success" }));
217
281
  const entry = {
218
282
  timestamp: Date.now(),
219
283
  data,
220
284
  error: !!errorInfo,
221
285
  errorType: errorInfo?.type || null, // "rate_limit" | "auth" | "network" | "unknown"
222
286
  errorStatus: errorInfo?.status || null, // HTTP 상태 코드
287
+ consecutive429s: state.consecutive429s,
288
+ nextRefreshBaseMs: state.baseDelayMs,
289
+ nextRefreshAt: Date.now() + state.delayMs,
223
290
  };
224
291
  // 에러 시 기존 유효 데이터 보존 (--% n/a 방지)
225
292
  if (errorInfo && data == null) {
@@ -234,9 +301,11 @@ export function writeClaudeUsageCache(data, errorInfo = null) {
234
301
 
235
302
  export async function fetchClaudeUsage(forceRefresh = false) {
236
303
  const existingSnapshot = readClaudeUsageSnapshot();
237
- if (!forceRefresh && !existingSnapshot.shouldRefresh && existingSnapshot.data) {
238
- return existingSnapshot.data;
304
+ if (!forceRefresh && !existingSnapshot.shouldRefresh) {
305
+ return existingSnapshot.data || null;
239
306
  }
307
+ const cache = readJson(CLAUDE_USAGE_CACHE_PATH, null);
308
+ const consecutive429s = Number.isFinite(cache?.consecutive429s) ? cache.consecutive429s : 0;
240
309
  let creds = readClaudeCredentials();
241
310
  if (!creds) {
242
311
  writeClaudeUsageCache(null, { type: "auth", status: 0 });
@@ -262,11 +331,15 @@ export async function fetchClaudeUsage(forceRefresh = false) {
262
331
  : result.status === 401 || result.status === 403 ? "auth"
263
332
  : result.error === "timeout" || result.error === "network" ? "network"
264
333
  : "unknown";
265
- writeClaudeUsageCache(existingSnapshot.data, { type: errorType, status: result.status });
334
+ const pollState = errorType === "rate_limit"
335
+ ? computeClaudeUsagePollState({ consecutive429s, outcome: "rate_limit" })
336
+ : null;
337
+ writeClaudeUsageCache(existingSnapshot.data, { type: errorType, status: result.status }, pollState);
266
338
  return existingSnapshot.data || null;
267
339
  }
268
340
  const usage = parseClaudeUsageResponse(result.data);
269
- writeClaudeUsageCache(usage, usage ? null : { type: "unknown", status: 0 });
341
+ const pollState = usage ? computeClaudeUsagePollState({ outcome: "success" }) : null;
342
+ writeClaudeUsageCache(usage, usage ? null : { type: "unknown", status: 0 }, pollState);
270
343
  return usage;
271
344
  }
272
345
 
@@ -291,7 +364,7 @@ export function scheduleClaudeUsageRefresh() {
291
364
  try {
292
365
  if (existsSync(lockPath)) {
293
366
  const lockAge = Date.now() - readJson(lockPath, {}).t;
294
- if (lockAge < 30000) return; // 30초 이내 스폰 이력 건너뜀
367
+ if (lockAge < 1000) return; // 짧은 중복 스폰만 억제, 실제 폴링 주기는 nextRefreshAt가 제어
295
368
  }
296
369
  writeJsonSafe(lockPath, { t: Date.now() });
297
370
  } catch { /* 락 실패 무시 — 스폰 진행 */ }
package/hud/renderers.mjs CHANGED
@@ -122,6 +122,38 @@ export function getTeamRow(currentTier) {
122
122
  };
123
123
  }
124
124
 
125
+ // ============================================================================
126
+ // Mission Board 렌더러
127
+ // ============================================================================
128
+
129
+ const STATUS_ICON = {
130
+ active: "*",
131
+ idle: ".",
132
+ done: "+",
133
+ failed: "!",
134
+ };
135
+
136
+ /**
137
+ * Mission Board 상태를 1줄 compact 포맷으로 렌더링한다.
138
+ * 예: "MB: exec:+ ui:* perf:. [2/3 67%]"
139
+ * @param {{ agents: Array<{name: string, status: string, progress: number}>, dagLevel: number, totalProgress: number } | null} state
140
+ * @returns {string}
141
+ */
142
+ export function renderMissionBoard(state) {
143
+ if (!state) return "";
144
+
145
+ const { agents, totalProgress } = state;
146
+ const done = agents.filter((a) => a.status === "done").length;
147
+ const total = agents.length;
148
+
149
+ const parts = agents.map((a) => {
150
+ const icon = STATUS_ICON[a.status] ?? "?";
151
+ return `${a.name}:${icon}`;
152
+ });
153
+
154
+ return `MB: ${parts.join(" ")} [${done}/${total} ${totalProgress}%]`;
155
+ }
156
+
125
157
  // ============================================================================
126
158
  // 행 정렬 렌더링
127
159
  // ============================================================================
@@ -159,6 +191,7 @@ export function renderAlignedRows(rows) {
159
191
  // ============================================================================
160
192
  export function getMicroLine(contextView, claudeUsage, codexBuckets, geminiSession, geminiBucket, combinedSvPct) {
161
193
  const ctxView = contextView || buildContextUsageView({}, null);
194
+ const staleMarker = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
162
195
 
163
196
  // Claude 5h/1w
164
197
  const cF = claudeUsage?.fiveHourPercent != null ? clampPercent(claudeUsage.fiveHourPercent) : null;
@@ -198,7 +231,7 @@ export function getMicroLine(contextView, claudeUsage, codexBuckets, geminiSessi
198
231
  `${bold(codexWhite("x"))}${dim(":")}${xVal} ` +
199
232
  `${bold(geminiBlue("g"))}${dim(":")}${gVal} ` +
200
233
  `${dim("sv:")}${sv} ` +
201
- `${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}`;
234
+ `${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}${staleMarker}`;
202
235
  return truncateAnsi(line, cols);
203
236
  }
204
237
 
@@ -208,6 +241,7 @@ export function getMicroLine(contextView, claudeUsage, codexBuckets, geminiSessi
208
241
  export function getClaudeRows(currentTier, contextView, claudeUsage, combinedSvPct) {
209
242
  const ctxView = contextView || buildContextUsageView({}, null);
210
243
  const prefix = `${bold(claudeOrange("c"))}:`;
244
+ const staleMarker = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
211
245
 
212
246
  // 절약 퍼센트
213
247
  const svStr = formatSvPct(combinedSvPct || 0);
@@ -235,25 +269,25 @@ export function getClaudeRows(currentTier, contextView, claudeUsage, combinedSvP
235
269
  if (currentTier === "nano" || currentTier === "micro") {
236
270
  const fShort = hasData && fiveHourPercent != null ? colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange) : dim("--");
237
271
  const wShort = hasData && weeklyPercent != null ? colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange) : dim("--");
238
- const quotaSection = `${fShort}${dim("/")}${wShort}`;
272
+ const quotaSection = `${fShort}${dim("/")}${wShort}${staleMarker}`;
239
273
  return [{ prefix, left: quotaSection, right: "" }];
240
274
  }
241
275
 
242
276
  if (currentTier === "minimal") {
243
- const quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}`;
277
+ const quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}${staleMarker}`;
244
278
  const right = `${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}`;
245
279
  return [{ prefix, left: quotaSection, right }];
246
280
  }
247
281
 
248
282
  if (currentTier === "compact") {
249
- const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}`;
283
+ const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}${staleMarker}`;
250
284
  const warning = ctxView.warningTag ? ` ${dim("|")} ${yellow(ctxView.warningTag)}` : "";
251
285
  const contextSection = `${svSuffix} ${dim("|")} ${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}${warning}`;
252
286
  return [{ prefix, left: quotaSection, right: contextSection }];
253
287
  }
254
288
 
255
289
  // full tier (>= 120 cols)
256
- const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}`;
290
+ const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}${staleMarker}`;
257
291
  const warning = ctxView.warningTag ? ` ${dim("|")} ${yellow(ctxView.warningTag)}` : "";
258
292
  const contextSection = `${svSuffix} ${dim("|")} ${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}${warning}`;
259
293
  return [{ prefix, left: quotaSection, right: contextSection }];
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
+ }