@triflux/core 10.43.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.
Files changed (41) hide show
  1. package/hooks/codex-session-hook.mjs +67 -1
  2. package/hooks/cto-north-star-brief.mjs +3 -16
  3. package/hooks/error-context.mjs +3 -6
  4. package/hooks/hook-manager.mjs +1 -1
  5. package/hooks/hook-registry.json +0 -11
  6. package/hooks/keyword-rules.json +147 -23
  7. package/hooks/permission-safe-allow.mjs +36 -2
  8. package/hooks/session-start-fast.mjs +50 -1
  9. package/hooks/session-start-lake.mjs +3 -16
  10. package/hub/bridge.mjs +111 -5
  11. package/hub/cli-adapter-base.mjs +11 -2
  12. package/hub/codex-adapter.mjs +3 -0
  13. package/hub/gemini-adapter.mjs +29 -11
  14. package/hub/intent.mjs +24 -32
  15. package/hub/lib/memory-store.mjs +66 -30
  16. package/hub/lib/tfx-route-args.mjs +41 -4
  17. package/hub/reflexion.mjs +0 -57
  18. package/hub/router.mjs +474 -24
  19. package/hub/team/claude-daemon-control.mjs +2 -0
  20. package/hub/team/intervention.mjs +1 -1
  21. package/hub/team/retry-state-machine.mjs +41 -13
  22. package/hub/team/synapse-http.mjs +149 -4
  23. package/hud/cli-policy.mjs +69 -0
  24. package/hud/constants.mjs +20 -17
  25. package/hud/context-monitor.mjs +31 -21
  26. package/hud/hud-qos-status.mjs +81 -91
  27. package/hud/providers/claude.mjs +150 -84
  28. package/hud/providers/codex.mjs +10 -1
  29. package/hud/renderers.mjs +21 -59
  30. package/hud/utils.mjs +1 -15
  31. package/package.json +1 -1
  32. package/scripts/lib/agent-route-policy.mjs +367 -0
  33. package/scripts/lib/cli-agy.mjs +24 -4
  34. package/scripts/lib/cli-codex.mjs +22 -160
  35. package/scripts/lib/codex-profile-config.mjs +48 -4
  36. package/scripts/lib/gemini-profiles.mjs +113 -29
  37. package/scripts/lib/keyword-rules.mjs +24 -0
  38. package/scripts/lib/logger.mjs +60 -43
  39. package/scripts/lib/machine-profile.mjs +293 -0
  40. package/scripts/lib/mcp-health.mjs +44 -18
  41. package/hub/log-retention.mjs +0 -726
@@ -23,6 +23,8 @@ import {
23
23
  } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
25
 
26
+ import { normalizeCodexProfileName } from "../../scripts/lib/codex-profile-config.mjs";
27
+
26
28
  export const STATES = Object.freeze({
27
29
  PLANNING: "PLANNING",
28
30
  EXECUTING: "EXECUTING",
@@ -39,21 +41,29 @@ export const MODES = Object.freeze({
39
41
  });
40
42
 
41
43
  const ESCALATION_CHAIN_CONFIG_PATH = ".triflux/config/escalation-chain.json";
44
+ const DEFAULT_CLAUDE_MODEL = "fable";
42
45
 
43
- // Escalation chain (2026-05-27 정책):
44
- // 1. codex gpt-5.6-sol — 기존 최상위 Codex escalation 단계
45
- // 2. claude opus-4-8 — 최종 수단
46
+ // Escalation chain (2026-09-22 정책):
47
+ // 1. codex gpt-6-astra max: 최난도 단일 작업용 Codex escalation 단계
48
+ // 2. claude fable alias: Claude CLI가 최신 Fable tier로 해석하는 최종 수단
46
49
  const DEFAULT_ESCALATION_CHAIN = Object.freeze([
47
50
  Object.freeze({
48
51
  cli: "codex",
49
- model: "gpt-5.6-sol",
50
- profile: "gpt56_sol_xhigh",
52
+ model: "gpt-6-astra",
53
+ profile: "gpt6_astra_max",
51
54
  }),
52
- Object.freeze({ cli: "claude", model: "opus-4-8" }),
55
+ Object.freeze({ cli: "claude", model: DEFAULT_CLAUDE_MODEL }),
53
56
  ]);
54
57
 
55
58
  const STUCK_THRESHOLD = 3;
56
59
 
60
+ function normalizeEscalationProfile(profile) {
61
+ const normalized = normalizeCodexProfileName(profile);
62
+ return normalized === "ultra" || normalized === "gpt6_astra_ultra"
63
+ ? "gpt6_astra_max"
64
+ : normalized;
65
+ }
66
+
57
67
  export function createRetryStateMachine(options = {}) {
58
68
  const mode = options.mode || MODES.BOUNDED;
59
69
  const cliChain = resolveEscalationChain(options);
@@ -234,15 +244,25 @@ function resolveEscalationChain(options) {
234
244
  }
235
245
 
236
246
  const projectRoot = options.projectRoot || process.cwd();
237
- return (
238
- loadEscalationChainOverride(projectRoot) ||
239
- normalizeEscalationChain(
240
- DEFAULT_ESCALATION_CHAIN,
241
- "DEFAULT_ESCALATION_CHAIN",
242
- )
247
+ const projectOverride = loadEscalationChainOverride(projectRoot);
248
+ if (projectOverride) return projectOverride;
249
+
250
+ return normalizeEscalationChain(
251
+ resolveDefaultEscalationChain(options.env || process.env),
252
+ "DEFAULT_ESCALATION_CHAIN",
243
253
  );
244
254
  }
245
255
 
256
+ function resolveDefaultEscalationChain(env) {
257
+ const configuredModel = env?.TFX_ESCALATION_CLAUDE_MODEL?.trim();
258
+ if (!configuredModel) return DEFAULT_ESCALATION_CHAIN;
259
+
260
+ return [
261
+ DEFAULT_ESCALATION_CHAIN[0],
262
+ { cli: "claude", model: configuredModel },
263
+ ];
264
+ }
265
+
246
266
  export function loadEscalationChainOverride(projectRoot = process.cwd()) {
247
267
  const configPath = join(projectRoot, ESCALATION_CHAIN_CONFIG_PATH);
248
268
  if (!existsSync(configPath)) return null;
@@ -295,7 +315,9 @@ function normalizeEscalationChainEntry(entry, index, source) {
295
315
  model: entry.model,
296
316
  };
297
317
  if (typeof entry.profile === "string" && entry.profile.trim() !== "") {
298
- normalized.profile = entry.profile;
318
+ const profile = entry.profile.trim();
319
+ normalized.profile =
320
+ entry.cli === "codex" ? normalizeEscalationProfile(profile) : profile;
299
321
  }
300
322
  return normalized;
301
323
  }
@@ -328,6 +350,12 @@ export function loadSnapshot(snapshotFile) {
328
350
  `unsupported snapshot version: ${parsed.version} (expected 1)`,
329
351
  );
330
352
  }
353
+ if (Array.isArray(parsed.cliChain) && parsed.cliChain.length > 0) {
354
+ return {
355
+ ...parsed,
356
+ cliChain: normalizeEscalationChain(parsed.cliChain, "snapshot.cliChain"),
357
+ };
358
+ }
331
359
  return parsed;
332
360
  }
333
361
 
@@ -1,6 +1,9 @@
1
- import { readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir, hostname } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { normalizeRoleKey } from "../role-contract.mjs";
4
7
 
5
8
  const HUB_DEFAULT_PORT = 27888;
6
9
  // Same token file as hub/bridge.mjs (HUB_TOKEN_FILE). Kept inline rather than
@@ -21,6 +24,144 @@ function normalizeToken(raw) {
21
24
  return token || null;
22
25
  }
23
26
 
27
+ function opaqueId(prefix, value) {
28
+ return `${prefix}_${createHash("sha256")
29
+ .update(String(value || ""))
30
+ .digest("base64url")
31
+ .slice(0, 22)}`;
32
+ }
33
+
34
+ function normalizedProjectId(value) {
35
+ try {
36
+ return normalizeRoleKey({ project_id: value, role_kind: "cto" }).project_id;
37
+ } catch {
38
+ return "";
39
+ }
40
+ }
41
+
42
+ function resolveProjectId(cwd, meta, opts = {}) {
43
+ const explicit = meta?.project_id || process.env.TFX_PROJECT_ID;
44
+ if (explicit) return normalizedProjectId(explicit);
45
+ if (typeof opts.resolveProjectId === "function") {
46
+ return normalizedProjectId(opts.resolveProjectId(cwd));
47
+ }
48
+ let current = cwd;
49
+ while (current) {
50
+ const manifest = join(current, ".triflux", "project.json");
51
+ if (existsSync(manifest)) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
54
+ if (parsed?.schema_version !== "tfx.project.v1") return "";
55
+ return normalizedProjectId(parsed.project_id);
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+ const parent = dirname(current);
61
+ if (parent === current) break;
62
+ current = parent;
63
+ }
64
+ return "";
65
+ }
66
+
67
+ function inferSessionCli(meta, opts = {}) {
68
+ const explicit = String(meta?.cli || meta?.actor_cli || "")
69
+ .trim()
70
+ .toLowerCase();
71
+ if (explicit === "codex" || explicit === "claude") return explicit;
72
+ const entrypoint = String(opts.entrypoint ?? process.argv[1] ?? "");
73
+ if (entrypoint.includes("codex-session-hook")) return "codex";
74
+ if (entrypoint.includes("agy-session-hook")) return "other";
75
+ return "claude";
76
+ }
77
+
78
+ function resolveTmuxSession(meta, opts = {}) {
79
+ const explicit =
80
+ meta?.tmux_session || meta?.session_name || process.env.TFX_TMUX_SESSION;
81
+ if (explicit) return String(explicit).trim();
82
+ if (!process.env.TMUX) return "";
83
+ try {
84
+ const run =
85
+ opts.tmuxSessionName ||
86
+ (() =>
87
+ execFileSync("tmux", ["display-message", "-p", "#{session_name}"], {
88
+ encoding: "utf8",
89
+ timeout: 200,
90
+ windowsHide: true,
91
+ }));
92
+ return String(run() || "").trim();
93
+ } catch {
94
+ return "";
95
+ }
96
+ }
97
+
98
+ export function buildSynapseRegistrationMeta(meta, opts = {}) {
99
+ if (!meta || meta.sessionKind !== "interactive") return meta;
100
+ const sessionId = String(meta.sessionId || "").trim();
101
+ if (!sessionId) return meta;
102
+ const cwd = typeof meta.cwd === "string" ? meta.cwd : process.cwd();
103
+ const cli = inferSessionCli(meta, opts);
104
+ const hostId = String(
105
+ meta.host_id ||
106
+ process.env.TFX_HOST_ID ||
107
+ opaqueId("hst", opts.hostname?.() || hostname()),
108
+ ).trim();
109
+ const expiresAtMs = Number(opts.nowMs?.() ?? Date.now()) + 299000;
110
+ const transportLocators = [];
111
+ if (cli === "claude") {
112
+ transportLocators.push({
113
+ schema_version: "tfx.transport-locator.v1",
114
+ transport_id: opaqueId("trn", `claude-uds:${hostId}:${sessionId}`),
115
+ kind: "claude-uds",
116
+ host_id: hostId,
117
+ capabilities: ["probe", "wake", "activate", "interrupt"],
118
+ priority: 100,
119
+ expires_at_ms: expiresAtMs,
120
+ locator: {
121
+ session_id: sessionId,
122
+ bridge_id: String(
123
+ meta.bridge_id || process.env.TFX_BRIDGE_ID || "local-hub",
124
+ ),
125
+ },
126
+ });
127
+ }
128
+ const tmuxSession = resolveTmuxSession(meta, opts);
129
+ if (tmuxSession) {
130
+ const muxServerSource =
131
+ meta.mux_server_id ||
132
+ process.env.TFX_MUX_SERVER_ID ||
133
+ String(process.env.TMUX || "default").split(",", 1)[0];
134
+ transportLocators.push({
135
+ schema_version: "tfx.transport-locator.v1",
136
+ transport_id: opaqueId("trn", `tmux:${hostId}:${tmuxSession}`),
137
+ kind: "tmux",
138
+ host_id: hostId,
139
+ capabilities: ["probe", "wake", "activate", "interrupt"],
140
+ priority: cli === "codex" ? 100 : 50,
141
+ expires_at_ms: expiresAtMs,
142
+ locator: {
143
+ session_name: tmuxSession,
144
+ mux_server_id: opaqueId("mux", muxServerSource),
145
+ },
146
+ });
147
+ }
148
+ const directAgentId = `session:${sessionId}`;
149
+ return {
150
+ ...meta,
151
+ agent_id:
152
+ meta.agent_id ||
153
+ (directAgentId.length <= 64 && /^[a-zA-Z0-9._:-]+$/u.test(directAgentId)
154
+ ? directAgentId
155
+ : opaqueId("session", sessionId)),
156
+ cli,
157
+ project_id: resolveProjectId(cwd, meta, opts) || undefined,
158
+ session_id: meta.session_id || sessionId,
159
+ host_id: hostId,
160
+ transport_locators_json:
161
+ meta.transport_locators_json ?? JSON.stringify(transportLocators),
162
+ };
163
+ }
164
+
24
165
  // Mirrors hub/bridge.mjs:readHubToken — env override first, then the token file.
25
166
  // Missing token → null (backward-compatible with token-less hubs).
26
167
  function readHubToken() {
@@ -139,7 +280,11 @@ export function drainPendingSynapse(timeoutMs = 1000) {
139
280
  }
140
281
 
141
282
  export function registerSynapseSession(meta, opts = {}) {
142
- return fireAndForgetSynapse("/synapse/register", meta, opts);
283
+ return fireAndForgetSynapse(
284
+ "/synapse/register",
285
+ buildSynapseRegistrationMeta(meta, opts),
286
+ opts,
287
+ );
143
288
  }
144
289
 
145
290
  export function heartbeatSynapseSession(
@@ -0,0 +1,69 @@
1
+ // HUD CLI 표시 정책 판정 (순수 함수)
2
+ //
3
+ // machine profile 의 TFX_DISABLE_CODEX / TFX_DISABLE_ANTIGRAVITY 를 읽어서 HUD 가
4
+ // 어떤 프로바이더 행을 그릴지 정한다. 판독 자체는 scripts/lib/machine-profile.mjs
5
+ // 가 단일 정본이고, 이 모듈은 그 결과를 HUD 표시 결정으로 접기만 한다.
6
+ //
7
+ // 상대 경로 import 를 고정하는 이유: setup 이 hud/** 를 ~/.claude/hud/ 로,
8
+ // scripts/lib/* 를 ~/.claude/scripts/lib/ 로 각각 동기화한다. 그래서 설치본에서도
9
+ // ../scripts/lib/ 가 repo 와 같은 파일을 가리킨다. packages/core 와
10
+ // packages/triflux 미러도 같은 상대 레이아웃이다.
11
+ //
12
+ // fail-open 원칙: HUD 는 statusline 이라 어떤 예외에서도 죽으면 안 된다. 정책
13
+ // 판독이 실패하면 전부 표시하는 쪽으로 돌아간다.
14
+
15
+ import { resolveCliPolicy } from "../scripts/lib/machine-profile.mjs";
16
+
17
+ /**
18
+ * HUD 가 codex / antigravity 행을 그릴 수 있는지 판정한다.
19
+ *
20
+ * @param {NodeJS.ProcessEnv} [env]
21
+ * @param {{
22
+ * policy?: ReturnType<typeof resolveCliPolicy>|null,
23
+ * resolve?: (env: NodeJS.ProcessEnv, opts: object) => ReturnType<typeof resolveCliPolicy>,
24
+ * profile?: object,
25
+ * platform?: string,
26
+ * home?: string,
27
+ * }} [opts] policy 는 이미 계산한 정책 주입, resolve 는 테스트용 판독 시임이다.
28
+ * @returns {{ showCodex: boolean, antigravityAllowed: boolean, policy: object|null }}
29
+ */
30
+ export function resolveHudCliVisibility(env = process.env, opts = {}) {
31
+ try {
32
+ const resolver =
33
+ typeof opts.resolve === "function" ? opts.resolve : resolveCliPolicy;
34
+ const policy = opts.policy ?? resolver(env, opts);
35
+ return {
36
+ showCodex: policy?.codexDisabled !== true,
37
+ antigravityAllowed: policy?.antigravityDisabled !== true,
38
+ policy: policy ?? null,
39
+ };
40
+ } catch {
41
+ return { showCodex: true, antigravityAllowed: true, policy: null };
42
+ }
43
+ }
44
+
45
+ /**
46
+ * antigravity / gemini 슬롯 행을 그릴지 판정한다.
47
+ *
48
+ * antigravity 가 허용이면 기존 동작 그대로 항상 그린다. 정책으로 꺼졌을 때만
49
+ * gemini 폴백 정보가 하나라도 있는지 보고, 전부 비어 있으면 행을 생략한다.
50
+ *
51
+ * @param {{
52
+ * antigravityAllowed?: boolean,
53
+ * antigravityReady?: boolean,
54
+ * geminiEmail?: unknown,
55
+ * geminiBucket?: unknown,
56
+ * geminiSession?: unknown,
57
+ * }} [args]
58
+ * @returns {boolean}
59
+ */
60
+ export function shouldRenderGeminiFallbackRow({
61
+ antigravityAllowed = true,
62
+ antigravityReady = false,
63
+ geminiEmail = null,
64
+ geminiBucket = null,
65
+ geminiSession = null,
66
+ } = {}) {
67
+ if (antigravityAllowed || antigravityReady) return true;
68
+ return Boolean(geminiEmail || geminiBucket || geminiSession);
69
+ }
package/hud/constants.mjs CHANGED
@@ -71,7 +71,7 @@ export const CONTEXT_MONITOR_LOG_DIR = join(
71
71
  "logs",
72
72
  );
73
73
 
74
- // 원격 프로브 캐시 (tfx-remote-spawn)
74
+ // 원격 프로브 캐시 (tfx-remote)
75
75
  export const REMOTE_ENV_CACHE_DIR = join(
76
76
  homedir(),
77
77
  ".claude",
@@ -89,12 +89,19 @@ export const CLAUDE_CREDENTIALS_PATH = join(
89
89
  );
90
90
  export const CLAUDE_CREDENTIALS_FILENAME = ".credentials.json";
91
91
  export const CLAUDE_CODE_USER_AGENT_FALLBACK = "claude-code/2.x";
92
+ // Claude Code 2.1.x 평문 폴백 파일 위치: CLAUDE_SECURESTORAGE_CONFIG_DIR 가 정의돼 있으면 그 디렉터리
93
+ // (빈 문자열은 기본 ~/.claude), 아니면 CLAUDE_CONFIG_DIR. 기본 경로는 항상 마지막 후보로 둔다.
92
94
  export function getClaudeCredentialPaths(env = process.env) {
93
95
  const paths = [];
94
- if (env.CLAUDE_CONFIG_DIR) {
96
+ const secureDir = env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
97
+ if (secureDir !== undefined) {
98
+ if (secureDir) {
99
+ paths.push(join(secureDir.normalize("NFC"), CLAUDE_CREDENTIALS_FILENAME));
100
+ }
101
+ } else if (env.CLAUDE_CONFIG_DIR) {
95
102
  paths.push(join(env.CLAUDE_CONFIG_DIR, CLAUDE_CREDENTIALS_FILENAME));
96
103
  }
97
- paths.push(CLAUDE_CREDENTIALS_PATH);
104
+ if (paths[0] !== CLAUDE_CREDENTIALS_PATH) paths.push(CLAUDE_CREDENTIALS_PATH);
98
105
  return paths;
99
106
  }
100
107
  export function getClaudeCodeUserAgent(env = process.env) {
@@ -198,7 +205,16 @@ export const ANTIGRAVITY_SETTINGS_PATH = join(
198
205
  // /usage 실측 (2026-05-22): Gemini 4개 모델이 동일 reset window 공유 (family-pool).
199
206
  // Claude/GPT-OSS family는 Gemini와 별도 quota 윈도우.
200
207
  export const ANTIGRAVITY_MODEL_ABBREV = {
201
- // Gemini family — 통합 quota
208
+ // Gemini family — 통합 quota. 3.6+ Flash 는 세대 숫자 + 등급으로 구분한다.
209
+ "Gemini 3.8 Flash (High)": "8h",
210
+ "Gemini 3.8 Flash (Medium)": "8m",
211
+ "Gemini 3.8 Flash (Low)": "8l",
212
+ "Gemini 3.7 Flash (High)": "7h",
213
+ "Gemini 3.7 Flash (Medium)": "7m",
214
+ "Gemini 3.7 Flash (Low)": "7l",
215
+ "Gemini 3.6 Flash (High)": "6h",
216
+ "Gemini 3.6 Flash (Medium)": "6m",
217
+ "Gemini 3.6 Flash (Low)": "6l",
202
218
  "Gemini 3.5 Flash (High)": "Fh",
203
219
  "Gemini 3.5 Flash (Medium)": "Fm",
204
220
  "Gemini 3.5 Flash (Low)": "Fl",
@@ -242,12 +258,6 @@ export const GEMINI_RPM_TRACKER_PATH = join(
242
258
  "cache",
243
259
  "gemini-rpm-tracker.json",
244
260
  );
245
- export const SV_ACCUMULATOR_PATH = join(
246
- homedir(),
247
- ".claude",
248
- "cache",
249
- "sv-accumulator.json",
250
- );
251
261
  // 이전 .omc/ 경로 fallback (기존 환경 호환)
252
262
  export const LEGACY_GEMINI_QUOTA_CACHE = join(
253
263
  homedir(),
@@ -273,12 +283,6 @@ export const LEGACY_GEMINI_RPM_TRACKER = join(
273
283
  "state",
274
284
  "gemini_rpm_tracker.json",
275
285
  );
276
- export const LEGACY_SV_ACCUMULATOR = join(
277
- homedir(),
278
- ".omc",
279
- "state",
280
- "sv-accumulator.json",
281
- );
282
286
 
283
287
  export const GEMINI_RPM_WINDOW_MS = 60 * 1000; // 60초 슬라이딩 윈도우
284
288
  export const GEMINI_QUOTA_STALE_MS = 5 * 60 * 1000; // 5분
@@ -289,7 +293,6 @@ export const ACCOUNT_LABEL_WIDTH = 10;
289
293
  export const PROVIDER_PREFIX_WIDTH = 2;
290
294
  export const PERCENT_CELL_WIDTH = 3;
291
295
  export const TIME_CELL_INNER_WIDTH = 6;
292
- export const SV_CELL_WIDTH = 5;
293
296
 
294
297
  export const CLAUDE_REFRESH_FLAG = "--refresh-claude-usage";
295
298
  export const CODEX_REFRESH_FLAG = "--refresh-codex-rate-limits";
@@ -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,