@triflux/core 10.44.0 → 10.45.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.
@@ -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-04 기준): Opus 4.8 / Opus 4.7 / Opus 4.6 / Sonnet 4.6 = 1M,
19
- // Sonnet 4.5 / Haiku 4.5 = 200K. 그 외 모델은 [1m] suffix 로 opt-in 가능.
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 resolveModelLimit(modelId) {
33
- const id = normalizeModelId(modelId);
34
- if (!id) return DEFAULT_CONTEXT_LIMIT;
35
- if (id.includes("[1m]")) return MILLION_CONTEXT_LIMIT;
36
- for (const prefix of MODEL_HINT_1M_PREFIXES) {
37
- if (id.startsWith(prefix)) return MILLION_CONTEXT_LIMIT;
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
- export function shouldSuppressInfoOnlyContextStatus(modelId) {
40
+ function isMillionContextModel(modelId) {
43
41
  const id = normalizeModelId(modelId);
44
42
  if (!id) return false;
45
- return (
46
- id.startsWith("claude-opus-4-8") ||
47
- id.startsWith("claude-opus-4-7") ||
48
- id.includes("[1m]")
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" && shouldSuppressInfoOnlyContextStatus(modelId)
315
+ warning.level === "info" &&
316
+ shouldSuppressInfoOnlyContextStatus(modelId, stdinUsage.limitTokens)
307
317
  );
308
318
  return {
309
319
  usedTokens,
@@ -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
- preflightFresh && preflightCache?.antigravity?.ok === true;
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
- ...getClaudeRows(CURRENT_TIER, contextView, claudeUsage, combinedSvPct),
267
- getProviderRow(
268
- CURRENT_TIER,
269
- "codex",
270
- "x",
271
- codexWhite,
272
- qosProfile,
273
- accountsConfig,
274
- accountsState,
275
- codexQuotaData,
276
- codexEmail,
277
- codexSv,
278
- null,
279
- ),
280
- getProviderRow(
281
- CURRENT_TIER,
282
- antigravityReady ? "antigravity" : "gemini",
283
- antigravityReady ? "a" : "g",
284
- geminiBlue,
285
- qosProfile,
286
- accountsConfig,
287
- accountsState,
288
- antigravityReady
289
- ? {
290
- type: "antigravity",
291
- pools: {
292
- current: antigravitySlot1Bucket,
293
- gemini_family: antigravityFamilyBucket,
294
- },
295
- currentAbbrev: antigravityAbbrev,
296
- currentModel: antigravityModel,
297
- currentFamily: antigravityModelFamily,
298
- }
299
- : geminiQuotaData,
300
- antigravityReady ? antigravityEmail : geminiEmail,
301
- antigravityReady ? null : geminiSv,
302
- null,
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 래핑 (rows 순서: [claude, codex, gemini])
352
- if (outputLines.length >= 3) {
353
- if (!codexActive) outputLines[1] = `${DIM}${outputLines[1]}${RESET}`;
354
- if (!geminiActive) outputLines[2] = `${DIM}${outputLines[2]}${RESET}`;
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 내용 보호
@@ -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 configDir = env.CLAUDE_CONFIG_DIR;
121
- if (configDir) {
122
- const hash = createHash("sha256")
123
- .update(configDir)
124
- .digest("hex")
125
- .slice(0, 8);
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
- function isCredentialExpired(creds) {
132
- return creds.expiresAt != null && creds.expiresAt <= Date.now();
133
- }
137
+ const KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/;
138
+ const KEYCHAIN_ACCOUNT_FALLBACK = "claude-code-user";
134
139
 
135
- function getKeychainAccount() {
140
+ // Claude Code 와 같은 계정 규칙: USER 환경변수 → os.userInfo().username → 허용 문자 밖이면 고정 대체값.
141
+ export function getKeychainAccount(env = process.env) {
142
+ let name = null;
136
143
  try {
137
- return userInfo().username?.trim() || null;
144
+ name = env.USER || userInfo().username;
138
145
  } catch {
139
- return null;
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 readClaudeKeychainCredential(
154
+ function readClaudeKeychainRaw(
144
155
  serviceName,
145
156
  account,
146
157
  execFileSyncFn = execFileSync,
147
158
  ) {
148
159
  try {
149
- const args = account
150
- ? ["find-generic-password", "-s", serviceName, "-a", account, "-w"]
151
- : ["find-generic-password", "-s", serviceName, "-w"];
152
- const raw = execFileSyncFn("security", args, { encoding: "utf8" });
153
- return normalizeClaudeCredentials(JSON.parse(raw.trim()), "keychain");
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 serviceName = getKeychainServiceName(env);
164
- const candidateAccounts = [];
165
- const username = getKeychainAccount();
166
- if (username) candidateAccounts.push(username);
167
- candidateAccounts.push(undefined);
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
- for (const filePath of getClaudeCredentialPaths(env)) {
191
- try {
192
- const fileCreds = normalizeClaudeCredentials(
193
- readCredentialFile(filePath),
194
- "file",
195
- );
196
- if (fileCreds) return fileCreds;
197
- } catch {
198
- /* try next credential source */
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
- if (platform === "darwin") {
203
- const keychainCreds = readClaudeKeychainCredentials(execFileSyncFn, env);
204
- if (keychainCreds) return keychainCreds;
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
- function serializeClaudeCredentials(creds) {
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 username = getKeychainAccount();
292
- const accountArgs = username ? ["-a", username] : [];
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 = () => readJson(CLAUDE_CREDENTIALS_PATH, null),
312
- writeCredentialFile = (data) =>
313
- writeFileSync(CLAUDE_CREDENTIALS_PATH, JSON.stringify(data, null, 2)),
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?.source === "env") return;
390
+ if (!creds || creds.source === "env") return;
320
391
 
321
- let data = null;
322
- try {
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
- writeCredentialFile(data);
395
+ writeClaudeKeychainCredentials(creds, execFileSyncFn, env);
335
396
  } catch {
336
- /* 파일 쓰기 실패 무시 */
397
+ /* Keychain 쓰기 실패 무시 */
337
398
  }
399
+ return;
338
400
  }
339
401
 
340
- if (platform === "darwin") {
402
+ if (creds.source === "file") {
341
403
  try {
342
- writeClaudeKeychainCredentials(creds, execFileSyncFn, env);
404
+ writeClaudeFileCredentials(
405
+ creds,
406
+ readCredentialFile,
407
+ writeCredentialFile,
408
+ );
343
409
  } catch {
344
- /* Keychain 쓰기 실패 무시 */
410
+ /* 파일 쓰기 실패 무시 */
345
411
  }
346
412
  }
347
413
  }
@@ -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.map((group) => group.latest),
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;