@triflux/core 10.44.0 → 10.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/codex-session-hook.mjs +67 -1
- package/hooks/cto-north-star-brief.mjs +3 -16
- package/hooks/error-context.mjs +3 -6
- package/hooks/hook-manager.mjs +1 -1
- package/hooks/hook-registry.json +0 -11
- package/hooks/keyword-rules.json +8 -4
- package/hooks/permission-safe-allow.mjs +36 -2
- package/hooks/session-start-fast.mjs +50 -1
- package/hooks/session-start-lake.mjs +3 -16
- package/hub/bridge.mjs +111 -5
- package/hub/dynamic-routing-engine.mjs +1 -1
- package/hub/gemini-adapter.mjs +29 -11
- package/hub/lib/memory-store.mjs +66 -30
- package/hub/lib/tfx-route-args.mjs +41 -4
- package/hub/reflexion.mjs +0 -57
- package/hub/router.mjs +202 -24
- package/hub/team/claude-daemon-control.mjs +2 -0
- package/hub/team/retry-state-machine.mjs +47 -16
- package/hud/cli-policy.mjs +69 -0
- package/hud/constants.mjs +20 -17
- package/hud/context-monitor.mjs +31 -21
- package/hud/hud-qos-status.mjs +81 -91
- package/hud/providers/claude.mjs +150 -84
- package/hud/providers/codex.mjs +10 -1
- package/hud/renderers.mjs +21 -59
- package/hud/utils.mjs +1 -15
- package/package.json +1 -1
- package/scripts/lib/agent-route-policy.mjs +47 -39
- package/scripts/lib/cli-agy.mjs +24 -4
- package/scripts/lib/codex-profile-config.mjs +45 -13
- package/scripts/lib/gemini-profiles.mjs +113 -29
- package/scripts/lib/keyword-rules.mjs +12 -0
- package/scripts/lib/logger.mjs +60 -43
- package/scripts/lib/machine-profile.mjs +293 -0
- package/scripts/lib/mcp-health.mjs +44 -18
package/hud/context-monitor.mjs
CHANGED
|
@@ -15,13 +15,12 @@ const MAX_CAPTURE_BYTES = 256 * 1024;
|
|
|
15
15
|
const MAX_TOP_KEYS = 20;
|
|
16
16
|
|
|
17
17
|
// stdin 이 context_window_size 를 제공하지 않을 때 모델 ID 로 한도를 추정한다.
|
|
18
|
-
// Anthropic 공식 문서(2026-
|
|
19
|
-
// Sonnet 4.5 / Haiku 4.5 = 200K
|
|
18
|
+
// Anthropic 공식 문서(2026-09-22 기준): Opus 4.6 이상, Sonnet 4.6/5,
|
|
19
|
+
// Fable 5/5.1 = 1M. Sonnet 4.5 / Haiku 4.5 = 200K이며 [1m]도 유지한다.
|
|
20
20
|
const MODEL_HINT_1M_PREFIXES = [
|
|
21
|
-
"claude-opus-4-8",
|
|
22
|
-
"claude-opus-4-7",
|
|
23
|
-
"claude-opus-4-6",
|
|
24
21
|
"claude-sonnet-4-6",
|
|
22
|
+
"claude-sonnet-5",
|
|
23
|
+
"claude-fable-5",
|
|
25
24
|
];
|
|
26
25
|
|
|
27
26
|
function normalizeModelId(modelId) {
|
|
@@ -29,24 +28,34 @@ function normalizeModelId(modelId) {
|
|
|
29
28
|
return String(modelId).toLowerCase();
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
function
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return DEFAULT_CONTEXT_LIMIT;
|
|
31
|
+
function isMillionContextOpusModel(modelId) {
|
|
32
|
+
const match = /^claude-opus-(\d+)(?:-(\d+))?(?:$|[-[\s])/u.exec(modelId);
|
|
33
|
+
if (!match) return false;
|
|
34
|
+
|
|
35
|
+
const major = Number(match[1]);
|
|
36
|
+
const minor = Number(match[2] || 0);
|
|
37
|
+
return major > 4 || (major === 4 && minor >= 6);
|
|
40
38
|
}
|
|
41
39
|
|
|
42
|
-
|
|
40
|
+
function isMillionContextModel(modelId) {
|
|
43
41
|
const id = normalizeModelId(modelId);
|
|
44
42
|
if (!id) return false;
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
if (id.includes("[1m]")) return true;
|
|
44
|
+
if (isMillionContextOpusModel(id)) return true;
|
|
45
|
+
return MODEL_HINT_1M_PREFIXES.some((prefix) => id.startsWith(prefix));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveModelLimit(modelId) {
|
|
49
|
+
return isMillionContextModel(modelId)
|
|
50
|
+
? MILLION_CONTEXT_LIMIT
|
|
51
|
+
: DEFAULT_CONTEXT_LIMIT;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function shouldSuppressInfoOnlyContextStatus(modelId, contextLimit = 0) {
|
|
55
|
+
const explicitLimit = Number(contextLimit);
|
|
56
|
+
return explicitLimit > 0
|
|
57
|
+
? explicitLimit >= MILLION_CONTEXT_LIMIT
|
|
58
|
+
: isMillionContextModel(modelId);
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
const WARNING_LEVELS = Object.freeze({
|
|
@@ -282,6 +291,7 @@ export function deriveContextLimit(stdin) {
|
|
|
282
291
|
}
|
|
283
292
|
|
|
284
293
|
export function buildContextUsageView(stdin, snapshot = null) {
|
|
294
|
+
const modelId = stdin?.model?.id ?? stdin?.model;
|
|
285
295
|
const stdinUsage = getStdinContextUsage(stdin);
|
|
286
296
|
if (!stdinUsage) {
|
|
287
297
|
return {
|
|
@@ -296,14 +306,14 @@ export function buildContextUsageView(stdin, snapshot = null) {
|
|
|
296
306
|
};
|
|
297
307
|
}
|
|
298
308
|
|
|
299
|
-
const modelId = stdin?.model?.id ?? stdin?.model;
|
|
300
309
|
const limitTokens = Math.max(1, stdinUsage.limitTokens);
|
|
301
310
|
const usedTokens = stdinUsage.usedTokens;
|
|
302
311
|
const percent = clampPercent((usedTokens / limitTokens) * 100);
|
|
303
312
|
|
|
304
313
|
const warning = classifyContextThreshold(percent);
|
|
305
314
|
const showInfoOnlyStatus = !(
|
|
306
|
-
warning.level === "info" &&
|
|
315
|
+
warning.level === "info" &&
|
|
316
|
+
shouldSuppressInfoOnlyContextStatus(modelId, stdinUsage.limitTokens)
|
|
307
317
|
);
|
|
308
318
|
return {
|
|
309
319
|
usedTokens,
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
// 각 모듈에서 색상, 터미널, 프로바이더, 렌더러를 가져와 조합한다.
|
|
6
6
|
// ============================================================================
|
|
7
7
|
|
|
8
|
+
import {
|
|
9
|
+
resolveHudCliVisibility,
|
|
10
|
+
shouldRenderGeminiFallbackRow,
|
|
11
|
+
} from "./cli-policy.mjs";
|
|
8
12
|
import {
|
|
9
13
|
bold,
|
|
10
14
|
claudeOrange,
|
|
@@ -28,10 +32,7 @@ import {
|
|
|
28
32
|
TFX_PREFLIGHT_CACHE_PATH,
|
|
29
33
|
TFX_PREFLIGHT_CACHE_STALE_MS,
|
|
30
34
|
} from "./constants.mjs";
|
|
31
|
-
import {
|
|
32
|
-
buildContextUsageView,
|
|
33
|
-
deriveContextLimit,
|
|
34
|
-
} from "./context-monitor.mjs";
|
|
35
|
+
import { buildContextUsageView } from "./context-monitor.mjs";
|
|
35
36
|
import { getMissionBoardState } from "./mission-board.mjs";
|
|
36
37
|
// Claude provider
|
|
37
38
|
import {
|
|
@@ -72,8 +73,6 @@ import {
|
|
|
72
73
|
getProviderRow,
|
|
73
74
|
getTeamRow,
|
|
74
75
|
readLatestBenchmarkDiff,
|
|
75
|
-
readSvAccumulator,
|
|
76
|
-
readTokenSavings,
|
|
77
76
|
renderAlignedRows,
|
|
78
77
|
renderMissionBoard,
|
|
79
78
|
} from "./renderers.mjs";
|
|
@@ -123,8 +122,14 @@ async function main() {
|
|
|
123
122
|
const preflightFresh =
|
|
124
123
|
Number.isFinite(preflightTimestamp) &&
|
|
125
124
|
Date.now() - preflightTimestamp <= TFX_PREFLIGHT_CACHE_STALE_MS;
|
|
125
|
+
// machine profile 의 TFX_DISABLE_* 정책. 차단된 CLI 는 행 자체를 그리지 않는다.
|
|
126
|
+
// antigravity 가 꺼지면 antigravityReady 를 강제로 내려서 기존 슬롯 로직이 그대로
|
|
127
|
+
// gemini 폴백 경로를 타게 한다.
|
|
128
|
+
const { showCodex, antigravityAllowed } = resolveHudCliVisibility();
|
|
126
129
|
const antigravityReady =
|
|
127
|
-
|
|
130
|
+
antigravityAllowed &&
|
|
131
|
+
preflightFresh &&
|
|
132
|
+
preflightCache?.antigravity?.ok === true;
|
|
128
133
|
const accountsConfig = readJson(ACCOUNTS_CONFIG_PATH, { providers: {} });
|
|
129
134
|
const accountsState = readJson(ACCOUNTS_STATE_PATH, { providers: {} });
|
|
130
135
|
const claudeUsageSnapshot = readClaudeUsageSnapshot();
|
|
@@ -168,35 +173,6 @@ async function main() {
|
|
|
168
173
|
const geminiQuota = geminiQuotaSnapshot.quota;
|
|
169
174
|
const missionBoardState = await getMissionBoardState();
|
|
170
175
|
|
|
171
|
-
// 누적 절약 데이터 읽기
|
|
172
|
-
const svSavings = readTokenSavings();
|
|
173
|
-
const svAccumulator = readSvAccumulator();
|
|
174
|
-
const _totalCostSaved =
|
|
175
|
-
svSavings?.totalSaved || svAccumulator?.totalCostSaved || 0;
|
|
176
|
-
|
|
177
|
-
// 세션/누적 토큰 → context 대비 절약 배수 (개별 provider sv%)
|
|
178
|
-
const ctxCapacity = deriveContextLimit(stdin);
|
|
179
|
-
let codexSv = null;
|
|
180
|
-
let codexAccumulatorSv = null;
|
|
181
|
-
if (svAccumulator?.codex?.tokens > 0) {
|
|
182
|
-
codexAccumulatorSv = svAccumulator.codex.tokens / ctxCapacity;
|
|
183
|
-
codexSv = codexAccumulatorSv;
|
|
184
|
-
} else if (codexBuckets) {
|
|
185
|
-
const main =
|
|
186
|
-
codexBuckets.codex || codexBuckets[Object.keys(codexBuckets)[0]];
|
|
187
|
-
if (main?.tokens?.total_tokens)
|
|
188
|
-
codexSv = main.tokens.total_tokens / ctxCapacity;
|
|
189
|
-
}
|
|
190
|
-
let geminiSv = null;
|
|
191
|
-
let geminiAccumulatorSv = null;
|
|
192
|
-
if (svAccumulator?.gemini?.tokens > 0) {
|
|
193
|
-
geminiAccumulatorSv = svAccumulator.gemini.tokens / ctxCapacity;
|
|
194
|
-
geminiSv = geminiAccumulatorSv;
|
|
195
|
-
} else {
|
|
196
|
-
const geminiTokens = geminiSession?.total || null;
|
|
197
|
-
geminiSv = geminiTokens ? geminiTokens / ctxCapacity : null;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
176
|
// Gemini: 3풀 버킷 추출 (Pro/Flash/Lite — 각 풀 내 모델들은 쿼터 공유)
|
|
201
177
|
const geminiModel = geminiSession?.model || "gemini-3-flash-preview";
|
|
202
178
|
const geminiBuckets = geminiQuota?.buckets || [];
|
|
@@ -220,19 +196,18 @@ async function main() {
|
|
|
220
196
|
const antigravitySlot1Bucket =
|
|
221
197
|
antigravityModelFamily === "gemini" ? antigravityFamilyBucket : null;
|
|
222
198
|
|
|
223
|
-
// 합산 절약은 svAccumulator 기반일 때만 표시한다.
|
|
224
|
-
// Codex bucket fallback은 account-window 누적 토큰이고 Gemini fallback은 latest
|
|
225
|
-
// session 토큰이라 서로 의미가 달라 합산하면 misleading cross-provider total이 된다.
|
|
226
|
-
const hasComparableSvAccumulator = Boolean(
|
|
227
|
-
codexAccumulatorSv != null || geminiAccumulatorSv != null,
|
|
228
|
-
);
|
|
229
|
-
const combinedSvPct = hasComparableSvAccumulator
|
|
230
|
-
? Math.round(((codexAccumulatorSv ?? 0) + (geminiAccumulatorSv ?? 0)) * 100)
|
|
231
|
-
: null;
|
|
232
|
-
|
|
233
199
|
// 인디케이터 인식 tier 선택 (stdin + Claude 사용량 기반)
|
|
234
200
|
const CURRENT_TIER = selectTier(stdin, claudeUsageSnapshot.data);
|
|
235
201
|
|
|
202
|
+
// antigravity/gemini 슬롯 표시 판정. nano 와 나머지 tier 가 같은 판정을 쓴다.
|
|
203
|
+
const showGeminiRow = shouldRenderGeminiFallbackRow({
|
|
204
|
+
antigravityAllowed,
|
|
205
|
+
antigravityReady,
|
|
206
|
+
geminiEmail,
|
|
207
|
+
geminiBucket,
|
|
208
|
+
geminiSession,
|
|
209
|
+
});
|
|
210
|
+
|
|
236
211
|
// nano tier: 1줄 모드 (극소 폭 또는 알림 배너 대응)
|
|
237
212
|
if (CURRENT_TIER === "nano") {
|
|
238
213
|
const microLine = getMicroLine(
|
|
@@ -241,8 +216,8 @@ async function main() {
|
|
|
241
216
|
codexBuckets,
|
|
242
217
|
antigravityReady ? null : geminiSession,
|
|
243
218
|
antigravityReady ? null : geminiBucket,
|
|
244
|
-
combinedSvPct,
|
|
245
219
|
antigravityReady ? "a" : "g",
|
|
220
|
+
{ showCodex, showGemini: showGeminiRow },
|
|
246
221
|
);
|
|
247
222
|
process.stdout.write(`\x1b[0m${microLine}\n`);
|
|
248
223
|
return;
|
|
@@ -262,46 +237,58 @@ async function main() {
|
|
|
262
237
|
session: geminiSession,
|
|
263
238
|
};
|
|
264
239
|
|
|
265
|
-
const rows = [
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
240
|
+
const rows = [...getClaudeRows(CURRENT_TIER, contextView, claudeUsage)];
|
|
241
|
+
|
|
242
|
+
// 정책으로 생략된 행이 있으면 뒤따르는 행의 인덱스가 밀린다. dim 래핑이
|
|
243
|
+
// 하드코딩 인덱스를 쓰지 않도록 실제 위치를 기록해 둔다.
|
|
244
|
+
let codexRowIndex = -1;
|
|
245
|
+
if (showCodex) {
|
|
246
|
+
codexRowIndex = rows.length;
|
|
247
|
+
rows.push(
|
|
248
|
+
getProviderRow(
|
|
249
|
+
CURRENT_TIER,
|
|
250
|
+
"codex",
|
|
251
|
+
"x",
|
|
252
|
+
codexWhite,
|
|
253
|
+
qosProfile,
|
|
254
|
+
accountsConfig,
|
|
255
|
+
accountsState,
|
|
256
|
+
codexQuotaData,
|
|
257
|
+
codexEmail,
|
|
258
|
+
null,
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let geminiRowIndex = -1;
|
|
264
|
+
if (showGeminiRow) {
|
|
265
|
+
geminiRowIndex = rows.length;
|
|
266
|
+
rows.push(
|
|
267
|
+
getProviderRow(
|
|
268
|
+
CURRENT_TIER,
|
|
269
|
+
antigravityReady ? "antigravity" : "gemini",
|
|
270
|
+
antigravityReady ? "a" : "g",
|
|
271
|
+
geminiBlue,
|
|
272
|
+
qosProfile,
|
|
273
|
+
accountsConfig,
|
|
274
|
+
accountsState,
|
|
275
|
+
antigravityReady
|
|
276
|
+
? {
|
|
277
|
+
type: "antigravity",
|
|
278
|
+
pools: {
|
|
279
|
+
current: antigravitySlot1Bucket,
|
|
280
|
+
gemini_family: antigravityFamilyBucket,
|
|
281
|
+
},
|
|
282
|
+
currentAbbrev: antigravityAbbrev,
|
|
283
|
+
currentModel: antigravityModel,
|
|
284
|
+
currentFamily: antigravityModelFamily,
|
|
285
|
+
}
|
|
286
|
+
: geminiQuotaData,
|
|
287
|
+
antigravityReady ? antigravityEmail : geminiEmail,
|
|
288
|
+
null,
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
305
292
|
|
|
306
293
|
const ctoStatus = readCtoStatus();
|
|
307
294
|
if (ctoStatus) {
|
|
@@ -348,10 +335,13 @@ async function main() {
|
|
|
348
335
|
|
|
349
336
|
const outputLines = renderAlignedRows(rows);
|
|
350
337
|
|
|
351
|
-
// 비활성 줄 dim
|
|
352
|
-
if (outputLines
|
|
353
|
-
|
|
354
|
-
|
|
338
|
+
// 비활성 줄 dim 래핑. 정책으로 생략된 행은 인덱스가 -1 이라 건너뛴다.
|
|
339
|
+
if (!codexActive && outputLines[codexRowIndex] != null) {
|
|
340
|
+
outputLines[codexRowIndex] = `${DIM}${outputLines[codexRowIndex]}${RESET}`;
|
|
341
|
+
}
|
|
342
|
+
if (!geminiActive && outputLines[geminiRowIndex] != null) {
|
|
343
|
+
outputLines[geminiRowIndex] =
|
|
344
|
+
`${DIM}${outputLines[geminiRowIndex]}${RESET}`;
|
|
355
345
|
}
|
|
356
346
|
|
|
357
347
|
// 선행 개행: 알림 배너(노란 글씨)가 빈 첫 줄에 오도록 → HUD 내용 보호
|
package/hud/providers/claude.mjs
CHANGED
|
@@ -116,70 +116,85 @@ function normalizeClaudeCredentials(data, source, supportsUsageApi = true) {
|
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Claude Code 2.1.x secure-storage 규칙(바이너리 실측, 2026-09-21):
|
|
120
|
+
// - CLAUDE_SECURESTORAGE_CONFIG_DIR 가 정의돼 있으면 그 값이 스코프다. 빈 문자열은 기본 디렉터리를
|
|
121
|
+
// 뜻해 접미사를 붙이지 않고, 값이 있으면 NFC 로 정규화한 뒤 해시한다.
|
|
122
|
+
// - 아니면 CLAUDE_CONFIG_DIR 원문(경로 확장·정규화 없이)을 sha256 해 앞 8자리를 붙인다.
|
|
123
|
+
// OMC 격리 세션의 `Claude Code-credentials-<hash>` 항목이 이 규칙으로 만들어진다.
|
|
119
124
|
export function getKeychainServiceName(env = process.env) {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return `${CLAUDE_KEYCHAIN_SERVICE}-${hash}`;
|
|
125
|
+
const secureDir = env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
|
126
|
+
let scope = "";
|
|
127
|
+
if (secureDir !== undefined) {
|
|
128
|
+
scope = secureDir ? secureDir.normalize("NFC") : "";
|
|
129
|
+
} else {
|
|
130
|
+
scope = env.CLAUDE_CONFIG_DIR || "";
|
|
127
131
|
}
|
|
128
|
-
return CLAUDE_KEYCHAIN_SERVICE;
|
|
132
|
+
if (!scope) return CLAUDE_KEYCHAIN_SERVICE;
|
|
133
|
+
const hash = createHash("sha256").update(scope).digest("hex").slice(0, 8);
|
|
134
|
+
return `${CLAUDE_KEYCHAIN_SERVICE}-${hash}`;
|
|
129
135
|
}
|
|
130
136
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
137
|
+
const KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
138
|
+
const KEYCHAIN_ACCOUNT_FALLBACK = "claude-code-user";
|
|
134
139
|
|
|
135
|
-
|
|
140
|
+
// Claude Code 와 같은 계정 규칙: USER 환경변수 → os.userInfo().username → 허용 문자 밖이면 고정 대체값.
|
|
141
|
+
export function getKeychainAccount(env = process.env) {
|
|
142
|
+
let name = null;
|
|
136
143
|
try {
|
|
137
|
-
|
|
144
|
+
name = env.USER || userInfo().username;
|
|
138
145
|
} catch {
|
|
139
|
-
|
|
146
|
+
name = null;
|
|
147
|
+
}
|
|
148
|
+
if (typeof name !== "string" || !KEYCHAIN_ACCOUNT_PATTERN.test(name)) {
|
|
149
|
+
return KEYCHAIN_ACCOUNT_FALLBACK;
|
|
140
150
|
}
|
|
151
|
+
return name;
|
|
141
152
|
}
|
|
142
153
|
|
|
143
|
-
function
|
|
154
|
+
function readClaudeKeychainRaw(
|
|
144
155
|
serviceName,
|
|
145
156
|
account,
|
|
146
157
|
execFileSyncFn = execFileSync,
|
|
147
158
|
) {
|
|
148
159
|
try {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
const raw = execFileSyncFn(
|
|
161
|
+
"security",
|
|
162
|
+
["find-generic-password", "-s", serviceName, "-a", account, "-w"],
|
|
163
|
+
{ encoding: "utf8" },
|
|
164
|
+
);
|
|
165
|
+
return JSON.parse(raw.trim());
|
|
154
166
|
} catch {
|
|
155
167
|
return null;
|
|
156
168
|
}
|
|
157
169
|
}
|
|
158
170
|
|
|
171
|
+
// Keychain 항목은 (service, account) 복합 키다. Claude Code 는 항상 `-a <account>` 로 쓰고 읽으므로
|
|
172
|
+
// 같은 키만 본다. `-a` 없는 조회는 "계정 없는 항목"이 아니라 같은 service 의 임의 계정 항목을 돌려주고,
|
|
173
|
+
// `add-generic-password` 는 `-a` 가 필수라 그런 항목에는 되쓸 수도 없다(2026-09-21 실측, exit 2).
|
|
174
|
+
export function readClaudeKeychainEntry({
|
|
175
|
+
execFileSyncFn = execFileSync,
|
|
176
|
+
env = process.env,
|
|
177
|
+
} = {}) {
|
|
178
|
+
const serviceName = getKeychainServiceName(env);
|
|
179
|
+
const account = getKeychainAccount(env);
|
|
180
|
+
const raw = readClaudeKeychainRaw(serviceName, account, execFileSyncFn);
|
|
181
|
+
return raw ? { raw, account, serviceName } : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
159
184
|
function readClaudeKeychainCredentials(
|
|
160
185
|
execFileSyncFn = execFileSync,
|
|
161
186
|
env = process.env,
|
|
162
187
|
) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
let expiredFallback = null;
|
|
170
|
-
for (const account of candidateAccounts) {
|
|
171
|
-
const creds = readClaudeKeychainCredential(
|
|
172
|
-
serviceName,
|
|
173
|
-
account,
|
|
174
|
-
execFileSyncFn,
|
|
175
|
-
);
|
|
176
|
-
if (!creds) continue;
|
|
177
|
-
if (!isCredentialExpired(creds)) return creds;
|
|
178
|
-
expiredFallback ??= creds;
|
|
179
|
-
}
|
|
180
|
-
return expiredFallback;
|
|
188
|
+
const entry = readClaudeKeychainEntry({ execFileSyncFn, env });
|
|
189
|
+
if (!entry) return null;
|
|
190
|
+
const creds = normalizeClaudeCredentials(entry.raw, "keychain");
|
|
191
|
+
// 만료된 항목도 그대로 돌려준다. 호출자가 refreshToken 으로 갱신한 뒤 같은 항목에 되쓴다.
|
|
192
|
+
return creds ? { ...creds, keychainAccount: entry.account } : null;
|
|
181
193
|
}
|
|
182
194
|
|
|
195
|
+
// 읽기 순서는 Claude Code 와 같다. macOS 에서는 Keychain 이 정본이고 평문 파일은 Keychain 을 못 쓸 때의
|
|
196
|
+
// 폴백이라, 옛 파일이 남아 있어도 Keychain 항목이 있으면 그쪽이 현재 로그인이다.
|
|
197
|
+
// 반환값에는 되쓰기 대상을 고정하기 위해 출처 위치(keychainAccount 또는 filePath)를 함께 싣는다.
|
|
183
198
|
export function readClaudeCredentials({
|
|
184
199
|
readCredentialFile = (filePath = CLAUDE_CREDENTIALS_PATH) =>
|
|
185
200
|
readJson(filePath, null),
|
|
@@ -187,21 +202,31 @@ export function readClaudeCredentials({
|
|
|
187
202
|
execFileSyncFn = execFileSync,
|
|
188
203
|
env = process.env,
|
|
189
204
|
} = {}) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
205
|
+
const tryKeychain = () =>
|
|
206
|
+
platform === "darwin"
|
|
207
|
+
? readClaudeKeychainCredentials(execFileSyncFn, env)
|
|
208
|
+
: null;
|
|
209
|
+
|
|
210
|
+
const tryFile = () => {
|
|
211
|
+
for (const filePath of getClaudeCredentialPaths(env)) {
|
|
212
|
+
try {
|
|
213
|
+
const fileCreds = normalizeClaudeCredentials(
|
|
214
|
+
readCredentialFile(filePath),
|
|
215
|
+
"file",
|
|
216
|
+
);
|
|
217
|
+
if (fileCreds) return { ...fileCreds, filePath };
|
|
218
|
+
} catch {
|
|
219
|
+
/* try next credential source */
|
|
220
|
+
}
|
|
199
221
|
}
|
|
200
|
-
|
|
222
|
+
return null;
|
|
223
|
+
};
|
|
201
224
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
225
|
+
const readers =
|
|
226
|
+
platform === "darwin" ? [tryKeychain, tryFile] : [tryFile, tryKeychain];
|
|
227
|
+
for (const read of readers) {
|
|
228
|
+
const creds = read();
|
|
229
|
+
if (creds) return creds;
|
|
205
230
|
}
|
|
206
231
|
|
|
207
232
|
if (env.ANTHROPIC_API_KEY) {
|
|
@@ -274,74 +299,115 @@ export function refreshClaudeAccessToken(refreshToken) {
|
|
|
274
299
|
});
|
|
275
300
|
}
|
|
276
301
|
|
|
277
|
-
|
|
302
|
+
// 기존 항목의 나머지 필드(scopes, subscriptionType, rateLimitTier, refreshTokenExpiresAt, mcpOAuth 등)를
|
|
303
|
+
// 보존하고 토큰 필드만 갈아 끼운다. 저장 형태(claudeAiOauth 래퍼 또는 legacy 평면)는 그대로 둔다.
|
|
304
|
+
function mergeClaudeCredentials(creds, existingRaw) {
|
|
305
|
+
const base =
|
|
306
|
+
existingRaw &&
|
|
307
|
+
typeof existingRaw === "object" &&
|
|
308
|
+
!Array.isArray(existingRaw)
|
|
309
|
+
? existingRaw
|
|
310
|
+
: null;
|
|
311
|
+
const hasWrapper =
|
|
312
|
+
!!base?.claudeAiOauth && typeof base.claudeAiOauth === "object";
|
|
278
313
|
const oauth = {
|
|
314
|
+
...(hasWrapper ? base.claudeAiOauth : (base ?? {})),
|
|
279
315
|
accessToken: creds.accessToken,
|
|
280
316
|
};
|
|
281
317
|
if (creds.expiresAt != null) oauth.expiresAt = creds.expiresAt;
|
|
282
318
|
if (creds.refreshToken) oauth.refreshToken = creds.refreshToken;
|
|
319
|
+
if (hasWrapper) return { ...base, claudeAiOauth: oauth };
|
|
320
|
+
if (base) return oauth;
|
|
283
321
|
return { claudeAiOauth: oauth };
|
|
284
322
|
}
|
|
285
323
|
|
|
324
|
+
// Claude Code 와 같은 쓰기 경로: 비밀값을 argv 에 두지 않도록 `security -i` 표준입력으로 명령 한 줄을
|
|
325
|
+
// 넘기고 payload 는 `-X` 16진수로 싣는다. 한 줄 상한(4032B)을 넘는 큰 항목만 argv 로 내려간다.
|
|
326
|
+
const KEYCHAIN_STDIN_LINE_LIMIT = 4032;
|
|
327
|
+
|
|
286
328
|
function writeClaudeKeychainCredentials(
|
|
287
329
|
creds,
|
|
288
330
|
execFileSyncFn = execFileSync,
|
|
289
331
|
env = process.env,
|
|
290
332
|
) {
|
|
291
|
-
const
|
|
292
|
-
|
|
333
|
+
const serviceName = getKeychainServiceName(env);
|
|
334
|
+
// 읽어 온 항목(keychainAccount)에 그대로 되쓴다. 현재 USER 와 달라도 다른 계정 항목을 만들지 않는다.
|
|
335
|
+
const account = creds.keychainAccount || getKeychainAccount(env);
|
|
336
|
+
const existingRaw = readClaudeKeychainRaw(
|
|
337
|
+
serviceName,
|
|
338
|
+
account,
|
|
339
|
+
execFileSyncFn,
|
|
340
|
+
);
|
|
341
|
+
const payload = mergeClaudeCredentials(creds, existingRaw);
|
|
342
|
+
const hex = Buffer.from(JSON.stringify(payload), "utf8").toString("hex");
|
|
343
|
+
const line = `add-generic-password -U -a "${account}" -s "${serviceName}" -X "${hex}"\n`;
|
|
344
|
+
if (line.length <= KEYCHAIN_STDIN_LINE_LIMIT) {
|
|
345
|
+
execFileSyncFn("security", ["-i"], {
|
|
346
|
+
input: line,
|
|
347
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
293
351
|
execFileSyncFn(
|
|
294
352
|
"security",
|
|
295
|
-
[
|
|
296
|
-
"add-generic-password",
|
|
297
|
-
"-s",
|
|
298
|
-
getKeychainServiceName(env),
|
|
299
|
-
...accountArgs,
|
|
300
|
-
"-w",
|
|
301
|
-
JSON.stringify(serializeClaudeCredentials(creds)),
|
|
302
|
-
"-U",
|
|
303
|
-
],
|
|
353
|
+
["add-generic-password", "-U", "-a", account, "-s", serviceName, "-X", hex],
|
|
304
354
|
{ stdio: "ignore" },
|
|
305
355
|
);
|
|
306
356
|
}
|
|
307
357
|
|
|
358
|
+
function writeClaudeFileCredentials(
|
|
359
|
+
creds,
|
|
360
|
+
readCredentialFile,
|
|
361
|
+
writeCredentialFile,
|
|
362
|
+
) {
|
|
363
|
+
const filePath = creds.filePath || CLAUDE_CREDENTIALS_PATH;
|
|
364
|
+
let data = null;
|
|
365
|
+
try {
|
|
366
|
+
data = readCredentialFile(filePath);
|
|
367
|
+
} catch {
|
|
368
|
+
data = null;
|
|
369
|
+
}
|
|
370
|
+
// 파일이 사라졌으면 새로 만들지 않는다 (Claude Code 가 Keychain 으로 옮긴 뒤 지운 경우)
|
|
371
|
+
if (!data) return;
|
|
372
|
+
writeCredentialFile(mergeClaudeCredentials(creds, data), filePath);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 되쓰기는 자격증명을 읽어 온 저장소에만 한다. Keychain 출처를 파일에 쓰거나 파일 출처를 Keychain 에 쓰면
|
|
376
|
+
// 계정이 다른 두 저장소가 섞이고(2026-09 실측: 파일=max 계정 만료분, Keychain=pro 계정 현재 로그인),
|
|
377
|
+
// Claude Code 가 실제로 쓰는 Keychain 항목을 옛 계정 토큰으로 덮어쓴다.
|
|
308
378
|
export function writeBackClaudeCredentials(
|
|
309
379
|
creds,
|
|
310
380
|
{
|
|
311
|
-
readCredentialFile = (
|
|
312
|
-
|
|
313
|
-
|
|
381
|
+
readCredentialFile = (filePath = CLAUDE_CREDENTIALS_PATH) =>
|
|
382
|
+
readJson(filePath, null),
|
|
383
|
+
writeCredentialFile = (data, filePath = CLAUDE_CREDENTIALS_PATH) =>
|
|
384
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 }),
|
|
314
385
|
platform = process.platform,
|
|
315
386
|
execFileSyncFn = execFileSync,
|
|
316
387
|
env = process.env,
|
|
317
388
|
} = {},
|
|
318
389
|
) {
|
|
319
|
-
if (creds
|
|
390
|
+
if (!creds || creds.source === "env") return;
|
|
320
391
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
data = readCredentialFile();
|
|
324
|
-
} catch {
|
|
325
|
-
data = null;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
if (data) {
|
|
329
|
-
const target = data.claudeAiOauth || data;
|
|
330
|
-
target.accessToken = creds.accessToken;
|
|
331
|
-
if (creds.expiresAt != null) target.expiresAt = creds.expiresAt;
|
|
332
|
-
if (creds.refreshToken) target.refreshToken = creds.refreshToken;
|
|
392
|
+
if (creds.source === "keychain") {
|
|
393
|
+
if (platform !== "darwin") return;
|
|
333
394
|
try {
|
|
334
|
-
|
|
395
|
+
writeClaudeKeychainCredentials(creds, execFileSyncFn, env);
|
|
335
396
|
} catch {
|
|
336
|
-
/*
|
|
397
|
+
/* Keychain 쓰기 실패 무시 */
|
|
337
398
|
}
|
|
399
|
+
return;
|
|
338
400
|
}
|
|
339
401
|
|
|
340
|
-
if (
|
|
402
|
+
if (creds.source === "file") {
|
|
341
403
|
try {
|
|
342
|
-
|
|
404
|
+
writeClaudeFileCredentials(
|
|
405
|
+
creds,
|
|
406
|
+
readCredentialFile,
|
|
407
|
+
writeCredentialFile,
|
|
408
|
+
);
|
|
343
409
|
} catch {
|
|
344
|
-
/*
|
|
410
|
+
/* 파일 쓰기 실패 무시 */
|
|
345
411
|
}
|
|
346
412
|
}
|
|
347
413
|
}
|
package/hud/providers/codex.mjs
CHANGED
|
@@ -89,6 +89,9 @@ export function expireStaleCodexBuckets(
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
const RESET_CLUSTER_TOLERANCE_SEC = 90;
|
|
92
|
+
// A probe may miss the instant immediately after a reset. Keep that brief
|
|
93
|
+
// polling gap, but never let a long-expired cached window win the fallback.
|
|
94
|
+
const STALE_WINDOW_FALLBACK_GRACE_SEC = CODEX_PROBE_TTL_MS / 1000;
|
|
92
95
|
|
|
93
96
|
function isNewerSnapshot(candidate, current) {
|
|
94
97
|
if (!current) return true;
|
|
@@ -149,7 +152,13 @@ function selectCodexWindowSnapshot(snapshots, nowSec, windowKey) {
|
|
|
149
152
|
})[0];
|
|
150
153
|
} else {
|
|
151
154
|
for (const snapshot of [
|
|
152
|
-
...groups
|
|
155
|
+
...groups
|
|
156
|
+
.filter(
|
|
157
|
+
(group) =>
|
|
158
|
+
nowSec - Number(group.latest[windowKey]?.resets_at) <=
|
|
159
|
+
STALE_WINDOW_FALLBACK_GRACE_SEC,
|
|
160
|
+
)
|
|
161
|
+
.map((group) => group.latest),
|
|
153
162
|
...ungrouped,
|
|
154
163
|
]) {
|
|
155
164
|
if (isNewerSnapshot(snapshot, selected)) selected = snapshot;
|