@triflux/core 10.0.0-alpha.1 → 10.0.0-alpha.2

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.
@@ -12,10 +12,13 @@
12
12
  */
13
13
  import { withRequestContext, getCorrelationId } from '../../scripts/lib/context.mjs';
14
14
  import { createModuleLogger } from '../../scripts/lib/logger.mjs';
15
+ import { createContextMonitor } from '../../hud/context-monitor.mjs';
15
16
 
16
17
  const log = createModuleLogger('hub');
18
+ const contextMonitor = createContextMonitor();
17
19
 
18
20
  const SKIP_PATHS = new Set(['/health', '/healthz', '/status', '/ready']);
21
+ const MAX_CAPTURE_BYTES = 256 * 1024;
19
22
 
20
23
  /**
21
24
  * 원본 request handler를 래핑하여 로깅 + 컨텍스트 전파를 추가한다.
@@ -44,6 +47,50 @@ export function wrapRequestHandler(handler) {
44
47
  },
45
48
  () => {
46
49
  const startTime = process.hrtime.bigint();
50
+ const reqChunks = [];
51
+ let reqBytes = 0;
52
+ let reqOverflow = false;
53
+
54
+ req.on('data', (chunk) => {
55
+ if (reqOverflow) return;
56
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
57
+ reqBytes += buf.length;
58
+ if (reqBytes > MAX_CAPTURE_BYTES) {
59
+ reqOverflow = true;
60
+ reqChunks.length = 0;
61
+ return;
62
+ }
63
+ reqChunks.push(buf);
64
+ });
65
+
66
+ const resChunks = [];
67
+ let resBytes = 0;
68
+ let resOverflow = false;
69
+
70
+ const originalWrite = res.write.bind(res);
71
+ const originalEnd = res.end.bind(res);
72
+
73
+ function captureResponseChunk(chunk) {
74
+ if (resOverflow || chunk == null) return;
75
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
76
+ resBytes += buf.length;
77
+ if (resBytes > MAX_CAPTURE_BYTES) {
78
+ resOverflow = true;
79
+ resChunks.length = 0;
80
+ return;
81
+ }
82
+ resChunks.push(buf);
83
+ }
84
+
85
+ res.write = function writePatched(chunk, ...args) {
86
+ captureResponseChunk(chunk);
87
+ return originalWrite(chunk, ...args);
88
+ };
89
+
90
+ res.end = function endPatched(chunk, ...args) {
91
+ captureResponseChunk(chunk);
92
+ return originalEnd(chunk, ...args);
93
+ };
47
94
 
48
95
  // 응답 헤더에 상관 ID 포함
49
96
  const cid = getCorrelationId();
@@ -55,15 +102,48 @@ export function wrapRequestHandler(handler) {
55
102
  const level = res.statusCode >= 500 ? 'error'
56
103
  : res.statusCode >= 400 ? 'warn'
57
104
  : 'info';
105
+ const reqBodyText = reqOverflow ? '' : Buffer.concat(reqChunks).toString('utf8');
106
+ const resBodyText = resOverflow ? '' : Buffer.concat(resChunks).toString('utf8');
107
+ const tokenSummary = contextMonitor.record({
108
+ requestBody: reqBodyText,
109
+ requestBytes: reqBytes || Number(req.headers['content-length'] || 0),
110
+ responseBody: resBodyText,
111
+ responseBytes: resBytes || Number(res.getHeader('content-length') || 0),
112
+ });
58
113
 
59
114
  log[level](
60
115
  {
61
116
  status: res.statusCode,
62
117
  duration: Math.round(duration * 100) / 100,
63
118
  contentLength: res.getHeader('content-length') || 0,
119
+ tokenUsage: {
120
+ request: tokenSummary.requestTokens,
121
+ response: tokenSummary.responseTokens,
122
+ total: tokenSummary.totalTokens,
123
+ context: tokenSummary.display,
124
+ warningLevel: tokenSummary.warningLevel,
125
+ overheadMs: tokenSummary.overheadMs,
126
+ },
64
127
  },
65
128
  'http.response',
66
129
  );
130
+
131
+ if (tokenSummary.warningLevel === 'critical') {
132
+ log.error(
133
+ { context: tokenSummary.display, message: tokenSummary.warningMessage },
134
+ 'context.critical',
135
+ );
136
+ } else if (tokenSummary.warningLevel === 'warn') {
137
+ log.warn(
138
+ { context: tokenSummary.display, message: tokenSummary.warningMessage },
139
+ 'context.warn',
140
+ );
141
+ } else if (tokenSummary.warningLevel === 'info') {
142
+ log.info(
143
+ { context: tokenSummary.display, message: tokenSummary.warningMessage },
144
+ 'context.info',
145
+ );
146
+ }
67
147
  });
68
148
 
69
149
  handler(req, res);
package/hub/router.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // hub/router.mjs — 실시간 라우팅/수신함 상태 관리자
2
2
  // SQLite는 감사 로그만 담당하고, 실제 배달 상태는 메모리에서 관리한다.
3
3
  import { EventEmitter, once } from 'node:events';
4
- import { uuidv7 } from './store.mjs';
4
+ import { uuidv7 } from './lib/uuidv7.mjs';
5
5
 
6
6
  const ASSIGN_PENDING_STATUSES = new Set(['queued', 'running']);
7
7
 
@@ -1,25 +1,27 @@
1
- // hub/team-bridge.mjscore ↔ team 디커플링 인터페이스
2
- // pipe.mjs, tools.mjs가 team/nativeProxy를 직접 참조하지 않도록
3
- // registry 패턴으로 구현을 주입받는다.
4
- // remote 미설치 시 graceful no-op 반환.
1
+ // @triflux/core — team-bridge 인터페이스
2
+ // remote 패키지가 런타임에 구현을 주입한다.
5
3
 
6
- const noTeam = async () => ({
7
- ok: false,
8
- error: { code: 'NO_TEAM', message: 'Team module not loaded' },
9
- });
4
+ /**
5
+ * @typedef {object} TeamBridge
6
+ * @property {(args?: object) => Promise<object>} teamInfo
7
+ * @property {(args?: object) => Promise<object>} teamTaskList
8
+ * @property {(args?: object) => Promise<object>} teamTaskUpdate
9
+ * @property {(args?: object) => Promise<object>} teamSendMessage
10
+ */
10
11
 
11
- let _impl = {
12
- teamInfo: noTeam,
13
- teamTaskList: noTeam,
14
- teamTaskUpdate: noTeam,
15
- teamSendMessage: noTeam,
16
- };
12
+ /** @type {TeamBridge | null} */
13
+ let _bridge = null;
17
14
 
15
+ /**
16
+ * @param {TeamBridge | null} impl
17
+ */
18
18
  export function registerTeamBridge(impl) {
19
- _impl = { ..._impl, ...impl };
19
+ _bridge = impl;
20
20
  }
21
21
 
22
- export function teamInfo(args) { return _impl.teamInfo(args); }
23
- export function teamTaskList(args) { return _impl.teamTaskList(args); }
24
- export function teamTaskUpdate(args) { return _impl.teamTaskUpdate(args); }
25
- export function teamSendMessage(args) { return _impl.teamSendMessage(args); }
22
+ /**
23
+ * @returns {TeamBridge | null}
24
+ */
25
+ export function getTeamBridge() {
26
+ return _bridge;
27
+ }
package/hud/constants.mjs CHANGED
@@ -12,6 +12,13 @@ export const ACCOUNTS_STATE_PATH = join(homedir(), ".omc", "state", "cli_account
12
12
 
13
13
  // tfx-multi 상태 (v2.2 HUD 통합)
14
14
  export const TEAM_STATE_PATH = join(homedir(), ".claude", "cache", "tfx-hub", "team-state.json");
15
+ export const CONTEXT_MONITOR_CACHE_PATH = join(homedir(), ".claude", "cache", "tfx-hub", "context-monitor.json");
16
+ export const CONTEXT_MONITOR_LEGACY_PATH = join(homedir(), ".omc", "state", "context-monitor.json");
17
+ export const CONTEXT_MONITOR_LOG_DIR = join(homedir(), ".omc", "logs");
18
+
19
+ // 원격 프로브 캐시 (tfx-remote-spawn)
20
+ export const REMOTE_ENV_CACHE_DIR = join(homedir(), ".claude", "cache", "tfx-hub", "remote-env");
21
+ export const REMOTE_ENV_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24시간
15
22
 
16
23
  // Claude OAuth Usage API (api.anthropic.com/api/oauth/usage)
17
24
  export const CLAUDE_CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
@@ -0,0 +1,403 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+
6
+ import {
7
+ CONTEXT_MONITOR_CACHE_PATH,
8
+ CONTEXT_MONITOR_LEGACY_PATH,
9
+ CONTEXT_MONITOR_LOG_DIR,
10
+ } from "./constants.mjs";
11
+ import { clampPercent, formatTokenCount, readJsonMigrate } from "./utils.mjs";
12
+
13
+ const DEFAULT_CONTEXT_LIMIT = 200_000;
14
+ const MAX_CAPTURE_BYTES = 256 * 1024;
15
+ const MAX_TOP_KEYS = 20;
16
+
17
+ const WARNING_LEVELS = Object.freeze({
18
+ ok: { min: 0, message: "" },
19
+ info: { min: 60, message: "컨텍스트 절반 이상 사용" },
20
+ warn: { min: 80, message: "압축 권장" },
21
+ critical: { min: 90, message: "에이전트 분할 또는 세션 교체 권장" },
22
+ });
23
+
24
+ // Unlike clampPercent (rounds), this preserves decimals for precise threshold comparison.
25
+ function clampThresholdPercent(value) {
26
+ const numeric = Number(value);
27
+ if (!Number.isFinite(numeric)) return 0;
28
+ return Math.max(0, Math.min(100, numeric));
29
+ }
30
+
31
+ function safeWriteJson(filePath, data) {
32
+ try {
33
+ mkdirSync(dirname(filePath), { recursive: true });
34
+ writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
35
+ } catch {
36
+ // noop
37
+ }
38
+ }
39
+
40
+ function normalizeText(input) {
41
+ if (typeof input === "string") return input;
42
+ if (input == null) return "";
43
+ try {
44
+ return JSON.stringify(input);
45
+ } catch {
46
+ return String(input);
47
+ }
48
+ }
49
+
50
+ function safeJsonParse(raw) {
51
+ if (typeof raw !== "string" || !raw.trim()) return null;
52
+ try {
53
+ return JSON.parse(raw);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function toTokenEstimate(bytesOrText) {
60
+ if (typeof bytesOrText === "number" && Number.isFinite(bytesOrText)) {
61
+ if (bytesOrText <= 0) return 0;
62
+ return Math.max(1, Math.ceil(bytesOrText / 4));
63
+ }
64
+ const text = normalizeText(bytesOrText);
65
+ const bytes = Buffer.byteLength(text, "utf8");
66
+ if (bytes <= 0) return 0;
67
+ return Math.max(1, Math.ceil(bytes / 4));
68
+ }
69
+
70
+ function normalizeUsage(usage) {
71
+ if (!usage || typeof usage !== "object") return null;
72
+ const input = Number(usage.input_tokens ?? usage.inputTokens ?? 0);
73
+ const output = Number(usage.output_tokens ?? usage.outputTokens ?? 0);
74
+ const cacheCreation = Number(
75
+ usage.cache_creation_input_tokens
76
+ ?? usage.cacheCreationInputTokens
77
+ ?? usage.cache_creation_tokens
78
+ ?? 0,
79
+ );
80
+ const cacheRead = Number(
81
+ usage.cache_read_input_tokens
82
+ ?? usage.cacheReadInputTokens
83
+ ?? usage.cache_read_tokens
84
+ ?? 0,
85
+ );
86
+ const totalCandidate = Number(usage.total_tokens ?? usage.totalTokens ?? Number.NaN);
87
+ const total = Number.isFinite(totalCandidate) && totalCandidate > 0
88
+ ? totalCandidate
89
+ : input + output + cacheCreation + cacheRead;
90
+ if (!Number.isFinite(total) || total <= 0) return null;
91
+ return {
92
+ input: Math.max(0, Math.round(input)),
93
+ output: Math.max(0, Math.round(output)),
94
+ cacheCreation: Math.max(0, Math.round(cacheCreation)),
95
+ cacheRead: Math.max(0, Math.round(cacheRead)),
96
+ total: Math.max(0, Math.round(total)),
97
+ };
98
+ }
99
+
100
+ function extractUsage(payload) {
101
+ if (!payload || typeof payload !== "object") return null;
102
+
103
+ const queue = [payload];
104
+ const seen = new Set();
105
+
106
+ while (queue.length > 0) {
107
+ const current = queue.shift();
108
+ if (!current || typeof current !== "object") continue;
109
+ if (seen.has(current)) continue;
110
+ seen.add(current);
111
+
112
+ const directUsage = normalizeUsage(current.usage);
113
+ if (directUsage) return directUsage;
114
+
115
+ if (Array.isArray(current.content)) {
116
+ for (const item of current.content) queue.push(item);
117
+ }
118
+
119
+ for (const key of ["result", "payload", "response", "message", "data"]) {
120
+ if (current[key] && typeof current[key] === "object") {
121
+ queue.push(current[key]);
122
+ }
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ function bumpCounter(target, key, tokens) {
129
+ if (!key) return;
130
+ const prev = target[key] || 0;
131
+ target[key] = prev + tokens;
132
+ }
133
+
134
+ function pushTopKeys(inputMap, maxKeys = MAX_TOP_KEYS) {
135
+ const entries = Object.entries(inputMap || {});
136
+ entries.sort((a, b) => b[1] - a[1]);
137
+ return Object.fromEntries(entries.slice(0, maxKeys));
138
+ }
139
+
140
+ function extractFileKeys(args) {
141
+ if (!args || typeof args !== "object") return [];
142
+ const out = [];
143
+ const add = (value) => {
144
+ if (typeof value !== "string" || !value.trim()) return;
145
+ out.push(value.trim());
146
+ };
147
+ add(args.path);
148
+ add(args.file);
149
+ add(args.filename);
150
+ add(args.relative_path);
151
+ add(args.requestFilePath);
152
+ add(args.responseFilePath);
153
+ if (Array.isArray(args.paths)) {
154
+ for (const p of args.paths) add(p);
155
+ }
156
+ return Array.from(new Set(out)).slice(0, 5);
157
+ }
158
+
159
+ function detectSkillHints(payloadText) {
160
+ if (!payloadText) return [];
161
+ const matches = payloadText.match(/\$[a-z0-9_-]+/gi) || [];
162
+ return Array.from(new Set(matches.map((m) => m.replace(/^\$/, "")))).slice(0, 5);
163
+ }
164
+
165
+ export function estimateTokens(input) {
166
+ return toTokenEstimate(input);
167
+ }
168
+
169
+ export function parseUsageFromPayload(payload) {
170
+ return extractUsage(payload);
171
+ }
172
+
173
+ export function classifyContextThreshold(percent) {
174
+ const p = clampThresholdPercent(percent);
175
+ if (p >= WARNING_LEVELS.critical.min) return { level: "critical", message: WARNING_LEVELS.critical.message };
176
+ if (p >= WARNING_LEVELS.warn.min) return { level: "warn", message: WARNING_LEVELS.warn.message };
177
+ if (p >= WARNING_LEVELS.info.min) return { level: "info", message: WARNING_LEVELS.info.message };
178
+ return { level: "ok", message: "" };
179
+ }
180
+
181
+ export function formatContextUsage(usedTokens, limitTokens, percent = null) {
182
+ const used = Math.max(0, Math.round(Number(usedTokens) || 0));
183
+ const limit = Math.max(1, Math.round(Number(limitTokens) || DEFAULT_CONTEXT_LIMIT));
184
+ const pct = percent == null ? clampPercent((used / limit) * 100) : clampPercent(percent);
185
+ return `${formatTokenCount(used)}/${formatTokenCount(limit)} (${pct}%)`;
186
+ }
187
+
188
+ export function readContextMonitorSnapshot() {
189
+ return readJsonMigrate(CONTEXT_MONITOR_CACHE_PATH, CONTEXT_MONITOR_LEGACY_PATH, null);
190
+ }
191
+
192
+ function getStdinContextUsage(stdin) {
193
+ const limitTokens = Number(stdin?.context_window?.context_window_size || 0);
194
+ const nativePercent = Number(stdin?.context_window?.used_percentage);
195
+ const usage = stdin?.context_window?.current_usage || {};
196
+ const explicitUsed = Number(usage.total_tokens || 0);
197
+ const calculatedUsed = Number(usage.input_tokens || 0)
198
+ + Number(usage.cache_creation_input_tokens || 0)
199
+ + Number(usage.cache_read_input_tokens || 0);
200
+ const usedTokens = explicitUsed > 0 ? explicitUsed : calculatedUsed;
201
+
202
+ if (limitTokens > 0 && usedTokens > 0) {
203
+ return {
204
+ usedTokens: Math.round(usedTokens),
205
+ limitTokens: Math.round(limitTokens),
206
+ percent: clampPercent((usedTokens / limitTokens) * 100),
207
+ source: "stdin.tokens",
208
+ };
209
+ }
210
+
211
+ if (limitTokens > 0 && Number.isFinite(nativePercent)) {
212
+ const percent = clampPercent(nativePercent);
213
+ return {
214
+ usedTokens: Math.round((limitTokens * percent) / 100),
215
+ limitTokens: Math.round(limitTokens),
216
+ percent,
217
+ source: "stdin.percent",
218
+ };
219
+ }
220
+ return null;
221
+ }
222
+
223
+ export function buildContextUsageView(stdin, snapshot = null) {
224
+ const stdinUsage = getStdinContextUsage(stdin);
225
+ const monitor = snapshot || readContextMonitorSnapshot();
226
+ const fallbackLimit = Number(monitor?.limitTokens || DEFAULT_CONTEXT_LIMIT);
227
+
228
+ const usedTokens = stdinUsage?.usedTokens
229
+ ?? Number(monitor?.usedTokens || 0);
230
+ const limitTokens = stdinUsage?.limitTokens
231
+ ?? Math.max(1, fallbackLimit);
232
+ const percent = stdinUsage?.percent
233
+ ?? (limitTokens > 0 ? clampPercent((usedTokens / limitTokens) * 100) : 0);
234
+
235
+ const warning = classifyContextThreshold(percent);
236
+ return {
237
+ usedTokens,
238
+ limitTokens,
239
+ percent,
240
+ display: formatContextUsage(usedTokens, limitTokens, percent),
241
+ warningLevel: warning.level,
242
+ warningMessage: warning.message,
243
+ warningTag: warning.level === "warn" ? "⚠ 압축 권장"
244
+ : warning.level === "critical" ? "‼ 분할 권장"
245
+ : warning.level === "info" ? "ℹ 절반 이상 사용"
246
+ : "",
247
+ source: stdinUsage?.source || (monitor ? "monitor" : "none"),
248
+ };
249
+ }
250
+
251
+ export function createContextMonitor(options = {}) {
252
+ const limitTokens = Number(options.limitTokens || DEFAULT_CONTEXT_LIMIT);
253
+ const cachePath = options.cachePath || CONTEXT_MONITOR_CACHE_PATH;
254
+ const logsDir = options.logsDir || CONTEXT_MONITOR_LOG_DIR;
255
+ const sessionId = options.sessionId || randomUUID().slice(0, 8);
256
+ const registerExitHooks = options.registerExitHooks !== false;
257
+
258
+ const state = {
259
+ sessionId,
260
+ startedAt: new Date().toISOString(),
261
+ updatedAt: null,
262
+ limitTokens,
263
+ usedTokens: 0,
264
+ requestTokens: 0,
265
+ responseTokens: 0,
266
+ exactUsageTokens: 0,
267
+ totalUpdates: 0,
268
+ maxPercent: 0,
269
+ warningLevel: "ok",
270
+ warningMessage: "",
271
+ bySkill: {},
272
+ byFile: {},
273
+ byTool: {},
274
+ };
275
+
276
+ const writeSnapshot = () => {
277
+ const percent = clampPercent((state.usedTokens / state.limitTokens) * 100);
278
+ const warning = classifyContextThreshold(percent);
279
+ state.maxPercent = Math.max(state.maxPercent, percent);
280
+ state.warningLevel = warning.level;
281
+ state.warningMessage = warning.message;
282
+ state.updatedAt = new Date().toISOString();
283
+ safeWriteJson(cachePath, {
284
+ ...state,
285
+ display: formatContextUsage(state.usedTokens, state.limitTokens, percent),
286
+ percent,
287
+ bySkill: pushTopKeys(state.bySkill),
288
+ byFile: pushTopKeys(state.byFile),
289
+ byTool: pushTopKeys(state.byTool),
290
+ });
291
+ return { percent, warning };
292
+ };
293
+
294
+ const writeReport = (reason = "shutdown") => {
295
+ const percent = clampPercent((state.usedTokens / state.limitTokens) * 100);
296
+ const warning = classifyContextThreshold(percent);
297
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
298
+ const reportPath = join(logsDir, `context-usage-${state.sessionId}-${ts}.json`);
299
+ safeWriteJson(reportPath, {
300
+ sessionId: state.sessionId,
301
+ reason,
302
+ startedAt: state.startedAt,
303
+ endedAt: new Date().toISOString(),
304
+ summary: {
305
+ usedTokens: state.usedTokens,
306
+ limitTokens: state.limitTokens,
307
+ percent,
308
+ warningLevel: warning.level,
309
+ warningMessage: warning.message,
310
+ requestTokens: state.requestTokens,
311
+ responseTokens: state.responseTokens,
312
+ exactUsageTokens: state.exactUsageTokens,
313
+ updates: state.totalUpdates,
314
+ },
315
+ breakdown: {
316
+ skills: pushTopKeys(state.bySkill),
317
+ files: pushTopKeys(state.byFile),
318
+ tools: pushTopKeys(state.byTool),
319
+ },
320
+ });
321
+ return reportPath;
322
+ };
323
+
324
+ const record = ({
325
+ requestBody = null,
326
+ requestBytes = 0,
327
+ responseBody = null,
328
+ responseBytes = 0,
329
+ toolName = "",
330
+ } = {}) => {
331
+ const started = process.hrtime.bigint();
332
+ const reqObj = typeof requestBody === "object" ? requestBody : safeJsonParse(String(requestBody || ""));
333
+ const resObj = typeof responseBody === "object" ? responseBody : safeJsonParse(String(responseBody || ""));
334
+
335
+ const usage = parseUsageFromPayload(resObj);
336
+ const requestTokens = requestBytes > 0 ? toTokenEstimate(requestBytes) : toTokenEstimate(requestBody);
337
+ const responseTokens = usage?.total ?? (responseBytes > 0 ? toTokenEstimate(responseBytes) : toTokenEstimate(responseBody));
338
+ const totalTokens = Math.max(0, requestTokens + responseTokens);
339
+
340
+ const method = reqObj?.method || reqObj?.params?.name || "";
341
+ const name = toolName || reqObj?.params?.name || reqObj?.tool || "";
342
+ const args = reqObj?.params?.arguments || reqObj?.arguments || reqObj?.params || {};
343
+ const payloadText = normalizeText(requestBody).slice(0, MAX_CAPTURE_BYTES);
344
+ const skills = detectSkillHints(payloadText);
345
+ const files = extractFileKeys(args);
346
+
347
+ if (name) bumpCounter(state.byTool, String(name), totalTokens);
348
+ if (method?.includes("tool")) {
349
+ bumpCounter(state.byTool, String(method), totalTokens);
350
+ }
351
+ for (const file of files) bumpCounter(state.byFile, file, totalTokens);
352
+ for (const skill of skills) bumpCounter(state.bySkill, skill, totalTokens);
353
+
354
+ state.requestTokens += requestTokens;
355
+ state.responseTokens += responseTokens;
356
+ state.exactUsageTokens += usage?.total || 0;
357
+ state.usedTokens += totalTokens;
358
+ state.totalUpdates += 1;
359
+
360
+ const { percent, warning } = writeSnapshot();
361
+ const overheadMs = Number(process.hrtime.bigint() - started) / 1_000_000;
362
+
363
+ return {
364
+ requestTokens,
365
+ responseTokens,
366
+ totalTokens,
367
+ usedTokens: state.usedTokens,
368
+ limitTokens: state.limitTokens,
369
+ percent,
370
+ warningLevel: warning.level,
371
+ warningMessage: warning.message,
372
+ display: formatContextUsage(state.usedTokens, state.limitTokens, percent),
373
+ overheadMs: Math.round(overheadMs * 1000) / 1000,
374
+ };
375
+ };
376
+
377
+ let reportWritten = false;
378
+ const flush = (reason = "shutdown") => {
379
+ if (reportWritten) return null;
380
+ reportWritten = true;
381
+ return writeReport(reason);
382
+ };
383
+
384
+ if (registerExitHooks) {
385
+ const flushOnExit = () => {
386
+ try { flush("process.exit"); } catch {}
387
+ };
388
+ process.once("exit", flushOnExit);
389
+ process.once("SIGINT", flushOnExit);
390
+ process.once("SIGTERM", flushOnExit);
391
+ }
392
+
393
+ return {
394
+ record,
395
+ flush,
396
+ snapshot: () => ({
397
+ ...state,
398
+ bySkill: pushTopKeys(state.bySkill),
399
+ byFile: pushTopKeys(state.byFile),
400
+ byTool: pushTopKeys(state.byTool),
401
+ }),
402
+ };
403
+ }
@@ -13,14 +13,16 @@ import {
13
13
  GEMINI_PRO_POOL, GEMINI_FLASH_POOL,
14
14
  } from "./constants.mjs";
15
15
  import {
16
- readJson, readStdinJson, getContextPercent, getProviderAccountId, getCliArgValue,
16
+ readJson, readStdinJson, getProviderAccountId, getCliArgValue,
17
17
  } from "./utils.mjs";
18
18
  import { selectTier } from "./terminal.mjs";
19
19
 
20
20
  // Claude provider
21
21
  import {
22
22
  readClaudeUsageSnapshot, scheduleClaudeUsageRefresh, fetchClaudeUsage,
23
+ readClaudeContextSnapshot,
23
24
  } from "./providers/claude.mjs";
25
+ import { buildContextUsageView } from "./context-monitor.mjs";
24
26
 
25
27
  // Codex provider
26
28
  import {
@@ -79,6 +81,7 @@ async function main() {
79
81
  const accountsConfig = readJson(ACCOUNTS_CONFIG_PATH, { providers: {} });
80
82
  const accountsState = readJson(ACCOUNTS_STATE_PATH, { providers: {} });
81
83
  const claudeUsageSnapshot = readClaudeUsageSnapshot();
84
+ const contextSnapshot = readClaudeContextSnapshot();
82
85
  if (claudeUsageSnapshot.shouldRefresh) {
83
86
  scheduleClaudeUsageRefresh();
84
87
  }
@@ -99,6 +102,7 @@ async function main() {
99
102
 
100
103
  // 실측 데이터 추출
101
104
  const stdin = await stdinPromise;
105
+ const contextView = buildContextUsageView(stdin, contextSnapshot);
102
106
  const codexEmail = getCodexEmail();
103
107
  const geminiEmail = getGeminiEmail();
104
108
  const codexBuckets = codexSnapshot.buckets;
@@ -145,7 +149,7 @@ async function main() {
145
149
 
146
150
  // nano tier: 1줄 모드 (극소 폭 또는 알림 배너 대응)
147
151
  if (CURRENT_TIER === "nano") {
148
- const microLine = getMicroLine(stdin, claudeUsageSnapshot.data, codexBuckets,
152
+ const microLine = getMicroLine(contextView, claudeUsageSnapshot.data, codexBuckets,
149
153
  geminiSession, geminiBucket, combinedSvPct);
150
154
  process.stdout.write(`\x1b[0m${microLine}\n`);
151
155
  return;
@@ -160,7 +164,7 @@ async function main() {
160
164
  };
161
165
 
162
166
  const rows = [
163
- ...getClaudeRows(CURRENT_TIER, stdin, claudeUsageSnapshot.data, combinedSvPct),
167
+ ...getClaudeRows(CURRENT_TIER, contextView, claudeUsageSnapshot.data, combinedSvPct),
164
168
  getProviderRow(CURRENT_TIER, "codex", "x", codexWhite, qosProfile, accountsConfig, accountsState,
165
169
  codexQuotaData, codexEmail, codexSv, null),
166
170
  getProviderRow(CURRENT_TIER, "gemini", "g", geminiBlue, qosProfile, accountsConfig, accountsState,
@@ -194,7 +198,7 @@ async function main() {
194
198
  }
195
199
 
196
200
  // 선행 개행: 알림 배너(노란 글씨)가 빈 첫 줄에 오도록 → HUD 내용 보호
197
- const contextPercent = getContextPercent(stdin);
201
+ const contextPercent = contextView.percent;
198
202
  const leadingBreaks = contextPercent >= 85 ? "\n\n" : "\n";
199
203
  // 줄별 RESET: Claude Code TUI 스타일 간섭 방지 (색상 밝기 버그 수정)
200
204
  const resetedLines = outputLines.map(line => `\x1b[0m${line}`);
@@ -14,6 +14,7 @@ import {
14
14
  DEFAULT_OAUTH_CLIENT_ID, CLAUDE_REFRESH_FLAG,
15
15
  } from "../constants.mjs";
16
16
  import { readJson, writeJsonSafe, clampPercent, advanceToNextCycle } from "../utils.mjs";
17
+ import { readContextMonitorSnapshot } from "../context-monitor.mjs";
17
18
 
18
19
  // OMC 활성 여부에 따라 캐시 TTL 동적 결정
19
20
  function getClaudeUsageStaleMs() {
@@ -307,3 +308,7 @@ export function scheduleClaudeUsageRefresh() {
307
308
  writeClaudeUsageCache(null, { type: "network", status: 0, hint: String(spawnErr?.message || spawnErr) });
308
309
  }
309
310
  }
311
+
312
+ export function readClaudeContextSnapshot() {
313
+ return readContextMonitorSnapshot();
314
+ }