@triflux/core 10.46.0 → 10.48.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.
@@ -3,6 +3,7 @@
3
3
  import { spawn } from "node:child_process";
4
4
  import { argv, exit, stdin, stdout } from "node:process";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { writeCodexSessionRecord } from "../hub/lib/codex-session-registry.mjs";
6
7
  import { drainPendingSynapse as defaultDrainPendingSynapse } from "../hub/team/synapse-http.mjs";
7
8
  import {
8
9
  heartbeatInteractiveSession as defaultHeartbeatInteractiveSession,
@@ -145,6 +146,7 @@ export async function runCodexSessionHook(stdinData, opts = {}) {
145
146
  const mode = parsed.ok
146
147
  ? normalizeMode(opts.argvMode ?? argv[2], parsed.payload)
147
148
  : "";
149
+ const writeSessionRecord = opts.writeSessionRecord || writeCodexSessionRecord;
148
150
  const hubEnsureRun = opts.hubEnsureRun || defaultHubEnsureRun;
149
151
  const registerInteractiveSession =
150
152
  opts.registerInteractiveSession || defaultRegisterInteractiveSession;
@@ -157,6 +159,11 @@ export async function runCodexSessionHook(stdinData, opts = {}) {
157
159
 
158
160
  try {
159
161
  await runHookSideEffectsWithStdoutSuppressed(async () => {
162
+ if (mode === "register" || mode === "heartbeat") {
163
+ try {
164
+ writeSessionRecord(parsed.payload);
165
+ } catch {}
166
+ }
160
167
  if (mode === "register") {
161
168
  try {
162
169
  launchPresenceRegistration(parsed.payload);
@@ -13,6 +13,7 @@
13
13
  import { execFile, execFileSync } from "node:child_process";
14
14
  import { dirname, join } from "node:path";
15
15
  import { fileURLToPath, pathToFileURL } from "node:url";
16
+ import { resolveRoleControlSnapshot } from "../hub/lib/cto-env.mjs";
16
17
  import {
17
18
  buildSynapseTaskSummary,
18
19
  drainPendingSynapse,
@@ -95,6 +96,11 @@ async function defaultResolveParticipantLakeRoot(cwd) {
95
96
  */
96
97
  export async function emitParticipantSessionStarted(stdinData, seams = {}) {
97
98
  try {
99
+ if (
100
+ !resolveRoleControlSnapshot(seams.env || process.env).auto_collect_enabled
101
+ ) {
102
+ return null;
103
+ }
98
104
  const payload = parseStartPayload(stdinData);
99
105
  const sessionId = String(payload?.session_id || "").trim();
100
106
  if (!sessionId) return null;
@@ -45,7 +45,7 @@ function makeFallback(policy, trigger, inputsHash, now) {
45
45
  {
46
46
  cli: fb.default_cli ?? "codex",
47
47
  accountId: null,
48
- model: fb.default_model ?? "gpt-6-sol",
48
+ model: fb.default_model ?? "gpt-6.1-sol",
49
49
  riskTier: "safe",
50
50
  reason: trigger,
51
51
  },
@@ -0,0 +1,306 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { basename, join } from "node:path";
13
+
14
+ export function registryDir(env = process.env) {
15
+ if (env.TFX_CODEX_SESSION_REGISTRY_DIR) {
16
+ return env.TFX_CODEX_SESSION_REGISTRY_DIR;
17
+ }
18
+ return join(
19
+ env.XDG_STATE_HOME || join(env.HOME || homedir(), ".local", "state"),
20
+ "triflux",
21
+ "codex-sessions",
22
+ );
23
+ }
24
+
25
+ function defaultPsFn(pid) {
26
+ try {
27
+ const output = execFileSync(
28
+ "ps",
29
+ ["-o", "ppid=,comm=", "-p", String(pid)],
30
+ {
31
+ encoding: "utf8",
32
+ timeout: 1000,
33
+ },
34
+ );
35
+ const match = output.match(/^\s*(\d+)\s+(.+?)\s*$/);
36
+ return match ? { ppid: Number(match[1]), comm: match[2] } : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function findCodexAncestorPid({
43
+ startPid = process.ppid,
44
+ psFn = defaultPsFn,
45
+ maxDepth = 8,
46
+ } = {}) {
47
+ const seen = new Set();
48
+ let pid = startPid;
49
+ for (let depth = 0; depth < maxDepth; depth += 1) {
50
+ if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid)) return null;
51
+ seen.add(pid);
52
+ let processInfo;
53
+ try {
54
+ processInfo = psFn(pid);
55
+ } catch {
56
+ return null;
57
+ }
58
+ if (!processInfo) return null;
59
+ if (basename(String(processInfo.comm || "")) === "codex") return pid;
60
+ pid = Number(processInfo.ppid);
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function defaultTmuxFn(paneId) {
66
+ return execFileSync(
67
+ "tmux",
68
+ [
69
+ "display-message",
70
+ "-p",
71
+ "-t",
72
+ paneId,
73
+ "#{session_name}:#{window_id}.#{pane_id}",
74
+ ],
75
+ { encoding: "utf8", timeout: 1000 },
76
+ );
77
+ }
78
+
79
+ function defaultPanePidFn(paneId) {
80
+ return execFileSync(
81
+ "tmux",
82
+ ["display-message", "-p", "-t", paneId, "#{pane_pid}"],
83
+ { encoding: "utf8", timeout: 1000 },
84
+ );
85
+ }
86
+
87
+ function defaultCommandFn(pid) {
88
+ return execFileSync("ps", ["-o", "command=", "-p", String(pid)], {
89
+ encoding: "utf8",
90
+ timeout: 1000,
91
+ });
92
+ }
93
+
94
+ export function resolveTmuxCoordinate({ paneId, tmuxFn = defaultTmuxFn } = {}) {
95
+ if (typeof paneId !== "string" || !paneId.trim()) return null;
96
+ try {
97
+ const coordinate = tmuxFn(paneId).trim();
98
+ return /^.+:@\d+\.%\d+$/.test(coordinate) ? coordinate : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function aliveFromPid(pid) {
105
+ try {
106
+ process.kill(pid, 0);
107
+ return true;
108
+ } catch (error) {
109
+ return error?.code !== "ESRCH";
110
+ }
111
+ }
112
+
113
+ function pruneDeadRecords(dir) {
114
+ try {
115
+ for (const fileName of readdirSync(dir)) {
116
+ if (!/^\d+\.json$/.test(fileName)) continue;
117
+ const pid = Number(fileName.slice(0, -5));
118
+ if (Number.isSafeInteger(pid) && pid > 0 && !aliveFromPid(pid)) {
119
+ try {
120
+ unlinkSync(join(dir, fileName));
121
+ } catch {
122
+ // Registry upkeep must never affect the hook response.
123
+ }
124
+ }
125
+ }
126
+ } catch {
127
+ // A concurrent removal or permission change must not affect the write.
128
+ }
129
+ }
130
+
131
+ export function writeCodexSessionRecord(
132
+ payload,
133
+ {
134
+ env = process.env,
135
+ now = Date.now,
136
+ psFn = defaultPsFn,
137
+ tmuxFn,
138
+ panePidFn = defaultPanePidFn,
139
+ commandFn = defaultCommandFn,
140
+ dir = registryDir(env),
141
+ } = {},
142
+ ) {
143
+ if (typeof payload?.session_id !== "string" || !payload.session_id.trim()) {
144
+ return false;
145
+ }
146
+ const paneId = typeof env.TMUX_PANE === "string" ? env.TMUX_PANE.trim() : "";
147
+ if (!paneId) return false;
148
+ const pid = findCodexAncestorPid({ psFn });
149
+ if (pid === null) return false;
150
+
151
+ try {
152
+ const command = commandFn(pid);
153
+ if (
154
+ typeof command !== "string" ||
155
+ !command.trim() ||
156
+ command.includes("app-server")
157
+ ) {
158
+ return false;
159
+ }
160
+ // Split ps argv on whitespace; the first non-option after the binary is the subcommand.
161
+ const subcommand = command
162
+ .trim()
163
+ .split(/\s+/)
164
+ .slice(1)
165
+ .find((arg) => !arg.startsWith("-"));
166
+ if (subcommand === "exec") return false;
167
+ const panePidOutput = String(panePidFn(paneId)).trim();
168
+ const panePid = Number(panePidOutput);
169
+ if (
170
+ !/^\d+$/.test(panePidOutput) ||
171
+ !Number.isSafeInteger(panePid) ||
172
+ panePid <= 0
173
+ ) {
174
+ return false;
175
+ }
176
+ const seen = new Set();
177
+ let ancestor = pid;
178
+ for (let depth = 0; depth < 8 && ancestor !== panePid; depth += 1) {
179
+ if (
180
+ !Number.isSafeInteger(ancestor) ||
181
+ ancestor <= 0 ||
182
+ seen.has(ancestor)
183
+ ) {
184
+ return false;
185
+ }
186
+ seen.add(ancestor);
187
+ ancestor = Number(psFn(ancestor)?.ppid);
188
+ }
189
+ if (ancestor !== panePid) return false;
190
+ } catch {
191
+ // Daemons and stale panes cannot prove which TUI owns this thread.
192
+ return false;
193
+ }
194
+
195
+ const timestamp = typeof now === "function" ? now() : now;
196
+ const targetDir = dir;
197
+ const target = join(targetDir, `${pid}.json`);
198
+ let startedAt = timestamp;
199
+ let source = payload.source || null;
200
+ try {
201
+ const existing = JSON.parse(readFileSync(target, "utf8"));
202
+ if (existing.pid === pid && existing.sessionId === payload.session_id) {
203
+ if (Number.isFinite(existing.startedAt)) startedAt = existing.startedAt;
204
+ if (payload.source === undefined) source = existing.source || null;
205
+ }
206
+ } catch {
207
+ // New record, or an invalid previous record.
208
+ }
209
+
210
+ const record = {
211
+ version: 1,
212
+ writer: "triflux",
213
+ pid,
214
+ sessionId: payload.session_id,
215
+ cwd: payload.cwd || process.cwd(),
216
+ tmux: resolveTmuxCoordinate({ paneId, tmuxFn }),
217
+ tmuxPane: paneId,
218
+ source,
219
+ startedAt,
220
+ updatedAt: timestamp,
221
+ };
222
+
223
+ mkdirSync(targetDir, { recursive: true, mode: 0o700 });
224
+ const temporary = join(
225
+ targetDir,
226
+ `.${pid}.${process.pid}.${randomUUID()}.tmp`,
227
+ );
228
+ try {
229
+ writeFileSync(temporary, `${JSON.stringify(record)}\n`, {
230
+ mode: 0o600,
231
+ flag: "wx",
232
+ });
233
+ renameSync(temporary, target);
234
+ } catch (error) {
235
+ try {
236
+ unlinkSync(temporary);
237
+ } catch {
238
+ // Keep the original write error.
239
+ }
240
+ throw error;
241
+ }
242
+ pruneDeadRecords(targetDir);
243
+ return true;
244
+ }
245
+
246
+ export function readCodexSessionRecords({
247
+ dir = registryDir(),
248
+ isAlive = aliveFromPid,
249
+ } = {}) {
250
+ let fileNames;
251
+ try {
252
+ fileNames = readdirSync(dir);
253
+ } catch {
254
+ return [];
255
+ }
256
+ const records = [];
257
+ for (const fileName of fileNames) {
258
+ if (!fileName.endsWith(".json")) continue;
259
+ try {
260
+ const record = JSON.parse(readFileSync(join(dir, fileName), "utf8"));
261
+ if (
262
+ record &&
263
+ Number.isSafeInteger(record.pid) &&
264
+ record.pid > 0 &&
265
+ typeof record.sessionId === "string" &&
266
+ record.sessionId &&
267
+ isAlive(record.pid)
268
+ ) {
269
+ records.push(record);
270
+ }
271
+ } catch {
272
+ // Ignore a malformed or concurrently removed record.
273
+ }
274
+ }
275
+ return records;
276
+ }
277
+
278
+ export function codexThreadNames({ indexPath } = {}) {
279
+ const path =
280
+ indexPath ||
281
+ join(
282
+ process.env.CODEX_HOME || join(process.env.HOME || homedir(), ".codex"),
283
+ "session_index.jsonl",
284
+ );
285
+ let data;
286
+ try {
287
+ data = readFileSync(path, "utf8");
288
+ } catch {
289
+ return new Map();
290
+ }
291
+ const names = new Map();
292
+ for (const line of data.split("\n")) {
293
+ try {
294
+ const entry = JSON.parse(line);
295
+ if (
296
+ typeof entry.id === "string" &&
297
+ typeof entry.thread_name === "string"
298
+ ) {
299
+ names.set(entry.id, entry.thread_name);
300
+ }
301
+ } catch {
302
+ // Ignore malformed or incomplete lines.
303
+ }
304
+ }
305
+ return names;
306
+ }
@@ -38,10 +38,9 @@ export function resolveRoleControlSnapshot(env = process.env, options = {}) {
38
38
  const ctoManagerEnabled = masterEnabled && isEnvOn(env, "TFX_CTO_MANAGER");
39
39
  const leadManagerEnabled =
40
40
  ctoManagerEnabled && isEnvOn(env, "TFX_LEAD_MANAGER");
41
- const northStarEnabled =
42
- masterEnabled && !isEnvOff(env, "TFX_CTO_NORTH_STAR");
41
+ const northStarEnabled = masterEnabled && isEnvOn(env, "TFX_CTO_NORTH_STAR");
43
42
  const autoCollectEnabled =
44
- masterEnabled && !isEnvOff(env, "TFX_CTO_AUTO_COLLECT");
43
+ masterEnabled && isEnvOn(env, "TFX_CTO_AUTO_COLLECT");
45
44
  const values = {
46
45
  master_enabled: masterEnabled,
47
46
  cto_manager_enabled: ctoManagerEnabled,
@@ -15,7 +15,7 @@ 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-09-22 기준): Opus 4.6 이상, Sonnet 4.6/5,
18
+ // Anthropic 공식 문서(2026-09-30 기준): Opus 4.6 이상, Sonnet 4.6/5/5.5,
19
19
  // Fable 5/5.1 = 1M. Sonnet 4.5 / Haiku 4.5 = 200K이며 [1m]도 유지한다.
20
20
  const MODEL_HINT_1M_PREFIXES = [
21
21
  "claude-sonnet-4-6",
@@ -38,6 +38,8 @@ export function classifyBucket(bucket) {
38
38
 
39
39
  // primary/secondary를 window_minutes 기준으로 정규화
40
40
  // HUD 규약: primary=5h, secondary=1w
41
+ // window_minutes 가 비어 있으면 길이를 모르므로 보고된 칸에 둔다(app-server 스키마에서
42
+ // windowDurationMins 는 선택 필드). 길이를 알지만 5h/1w 가 아닌 버킷은 버린다.
41
43
  export function normalizeBuckets(rl) {
42
44
  let primary = null;
43
45
  let secondary = null;
@@ -46,6 +48,12 @@ export function normalizeBuckets(rl) {
46
48
  if (kind === "five_hour") primary = bucket;
47
49
  else if (kind === "weekly") secondary = bucket;
48
50
  }
51
+ if (!primary && rl.primary && rl.primary.window_minutes == null) {
52
+ primary = rl.primary;
53
+ }
54
+ if (!secondary && rl.secondary && rl.secondary.window_minutes == null) {
55
+ secondary = rl.secondary;
56
+ }
49
57
  return { primary, secondary };
50
58
  }
51
59
 
@@ -104,7 +112,35 @@ function isNewerSnapshot(candidate, current) {
104
112
  );
105
113
  }
106
114
 
107
- function selectCodexWindowSnapshot(snapshots, nowSec, windowKey) {
115
+ function positiveWindowMinutes(bucket) {
116
+ const minutes = Number(bucket?.window_minutes);
117
+ return minutes > 0 ? minutes : null;
118
+ }
119
+
120
+ // 계정 하나에는 같은 종류의 창이 한 번에 하나만 열린다. 브로커 계정이 없으면
121
+ // ~/.codex/sessions 와 HUD 라벨이 모두 ~/.codex/auth.json 계정 하나의 것이다. 그래서
122
+ // 세션 로그에 더 늦게 리셋되는 창이 나타났고 옛 창이 그 창이 열리기 전에만 관측됐다면,
123
+ // 옛 창은 조기 리셋(2026-09-23 실측)이나 계정 전환으로 끝난 창이다. 프로브로만 본 창은
124
+ // 다른 계정일 수 있어 근거로 쓰지 않는다. 새 창이 열린 뒤에도 관측된 창은 함께 도는
125
+ // 계정이므로 max-used 후보로 남긴다.
126
+ function isReplacedWindowGroup(group, other) {
127
+ if (other === group || !other.seenInSessions) return false;
128
+ if (other.minResetAt <= group.maxResetAt || !other.windowMinutes) {
129
+ return false;
130
+ }
131
+ const lastSeenMs = Date.parse(group.latest.timestamp);
132
+ if (!Number.isFinite(lastSeenMs)) return false;
133
+ // resets_at 은 실측으로 아래로 약 30초, 위로 1초 흔들리므로 가장 늦은 값으로 개시 시각을 잡는다.
134
+ const otherOpenedAtMs = (other.maxResetAt - other.windowMinutes * 60) * 1000;
135
+ return lastSeenMs <= otherOpenedAtMs;
136
+ }
137
+
138
+ function selectCodexWindowSnapshot(
139
+ snapshots,
140
+ nowSec,
141
+ windowKey,
142
+ replaceEndedWindows,
143
+ ) {
108
144
  const groups = [];
109
145
  const ungrouped = [];
110
146
 
@@ -126,19 +162,29 @@ function selectCodexWindowSnapshot(snapshots, nowSec, windowKey) {
126
162
  minResetAt: resetAt,
127
163
  maxResetAt: resetAt,
128
164
  latest: snapshot,
165
+ seenInSessions: !snapshot.probe,
166
+ windowMinutes: positiveWindowMinutes(snapshot[windowKey]),
129
167
  };
130
168
  groups.push(group);
131
169
  continue;
132
170
  }
133
171
 
172
+ if (!snapshot.probe) group.seenInSessions = true;
173
+ group.windowMinutes ??= positiveWindowMinutes(snapshot[windowKey]);
134
174
  group.minResetAt = Math.min(group.minResetAt, resetAt);
135
175
  group.maxResetAt = Math.max(group.maxResetAt, resetAt);
136
176
  if (isNewerSnapshot(snapshot, group.latest)) group.latest = snapshot;
137
177
  }
138
178
 
139
- const activeGroups = groups.filter(
179
+ const openGroups = groups.filter(
140
180
  (group) => Number(group.latest[windowKey]?.resets_at) > nowSec,
141
181
  );
182
+ const activeGroups = replaceEndedWindows
183
+ ? openGroups.filter(
184
+ (group) =>
185
+ !openGroups.some((other) => isReplacedWindowGroup(group, other)),
186
+ )
187
+ : openGroups;
142
188
  let selected = null;
143
189
  if (activeGroups.length > 0) {
144
190
  selected = activeGroups
@@ -168,16 +214,18 @@ function selectCodexWindowSnapshot(snapshots, nowSec, windowKey) {
168
214
  return { snapshot: selected, activeGroupCount: activeGroups.length };
169
215
  }
170
216
 
171
- function selectCodexSnapshot(snapshots, nowSec) {
217
+ function selectCodexSnapshot(snapshots, nowSec, replaceEndedWindows) {
172
218
  const primarySelection = selectCodexWindowSnapshot(
173
219
  snapshots,
174
220
  nowSec,
175
221
  "primary",
222
+ replaceEndedWindows,
176
223
  );
177
224
  const secondarySelection = selectCodexWindowSnapshot(
178
225
  snapshots,
179
226
  nowSec,
180
227
  "secondary",
228
+ replaceEndedWindows,
181
229
  );
182
230
  const primarySnapshot = primarySelection.snapshot;
183
231
  if (!primarySnapshot) return null;
@@ -198,7 +246,7 @@ function selectCodexSnapshot(snapshots, nowSec) {
198
246
  return selected;
199
247
  }
200
248
 
201
- function mergeRateLimitSnapshots(snapshots, nowSec) {
249
+ function mergeRateLimitSnapshots(snapshots, nowSec, replaceEndedWindows) {
202
250
  const merged = {};
203
251
  const codexSnapshots = [];
204
252
  for (const snapshot of snapshots) {
@@ -211,7 +259,11 @@ function mergeRateLimitSnapshots(snapshots, nowSec) {
211
259
  }
212
260
  }
213
261
 
214
- const codex = selectCodexSnapshot(codexSnapshots, nowSec);
262
+ const codex = selectCodexSnapshot(
263
+ codexSnapshots,
264
+ nowSec,
265
+ replaceEndedWindows,
266
+ );
215
267
  if (codex) merged.codex = codex;
216
268
  return merged;
217
269
  }
@@ -227,9 +279,16 @@ export function getCodexRateLimits({
227
279
  now = new Date(),
228
280
  maxLinesPerFile = 800,
229
281
  extraSnapshots = [],
282
+ // 교체된 창 판정(isReplacedWindowGroup). 브로커 Codex 계정이 있으면 끈다.
283
+ replaceEndedWindows = true,
230
284
  } = {}) {
231
285
  let syntheticBucket = null; // 최근 token_count에서 합성 (행 활성화 + 토큰 데이터용)
232
- const recentSnapshots = [...extraSnapshots];
286
+ // 프로브도 세션 로그와 같은 window_minutes 기준으로 5h/1w 슬롯을 정한다.
287
+ // app-server 는 5h 창이 없으면 주간 창을 primary 로 보낸다(2026-09-23 실측).
288
+ const recentSnapshots = extraSnapshots.map((snapshot) => ({
289
+ ...snapshot,
290
+ ...normalizeBuckets(snapshot),
291
+ }));
233
292
  const nowSec = Math.floor(now.getTime() / 1000);
234
293
 
235
294
  // 7일간 스캔: 실제 rate_limits 우선, 합성 버킷은 폴백
@@ -313,7 +372,11 @@ export function getCodexRateLimits({
313
372
  }
314
373
 
315
374
  const snapshots = dayOffset <= 1 ? recentSnapshots : daySnapshots;
316
- const mergedBuckets = mergeRateLimitSnapshots(snapshots, nowSec);
375
+ const mergedBuckets = mergeRateLimitSnapshots(
376
+ snapshots,
377
+ nowSec,
378
+ replaceEndedWindows,
379
+ );
317
380
  if (Object.keys(mergedBuckets).length > 0) {
318
381
  if (syntheticBucket) {
319
382
  const main =
@@ -369,10 +432,30 @@ export async function collectProbeSnapshots(now = new Date()) {
369
432
  );
370
433
  }
371
434
 
435
+ // 브로커 계정은 자기 CODEX_HOME 에서 돌기 때문에, 옛 창이 전환 전 계정의 창이어도 그
436
+ // 계정이 아직 쓰이고 있을 수 있다. accounts.json 의 codex 항목과 프로브용 인증 캐시로 판정한다.
437
+ export function hasBrokerCodexAccounts({
438
+ brokerDir = CODEX_BROKER_AUTH_CACHE_DIR,
439
+ } = {}) {
440
+ const accounts = readJson(join(brokerDir, "accounts.json"), null);
441
+ if (Array.isArray(accounts?.codex) && accounts.codex.length > 0) return true;
442
+ try {
443
+ return readdirSync(brokerDir).some((file) =>
444
+ /^codex-auth-.+\.json$/.test(file),
445
+ );
446
+ } catch {
447
+ return false;
448
+ }
449
+ }
450
+
372
451
  export async function refreshCodexRateLimitsCache() {
373
452
  const now = new Date();
374
453
  const extraSnapshots = await collectProbeSnapshots(now);
375
- const buckets = getCodexRateLimits({ now, extraSnapshots });
454
+ const buckets = getCodexRateLimits({
455
+ now,
456
+ extraSnapshots,
457
+ replaceEndedWindows: !hasBrokerCodexAccounts(),
458
+ });
376
459
  // buckets가 null이어도 캐시 갱신 (stale 데이터 제거)
377
460
  writeJsonSafe(CODEX_QUOTA_CACHE_PATH, { timestamp: Date.now(), buckets });
378
461
  return buckets;
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { resolveRoleControlSnapshot } from "../../hub/lib/cto-env.mjs";
3
4
  import {
4
5
  CTO_LAKE_CURRENT_RELATIVE_PATH,
5
6
  CTO_STATUS_LINE_MAX_CHARS,
@@ -53,6 +54,8 @@ function extractStatusLine(lines) {
53
54
  }
54
55
 
55
56
  export function readCtoStatus(options = {}) {
57
+ if (!resolveRoleControlSnapshot(options.env).auto_collect_enabled)
58
+ return null;
56
59
  try {
57
60
  const currentPath = resolveCurrentPath(options);
58
61
  const stat = statSync(currentPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.46.0",
3
+ "version": "10.48.0",
4
4
  "description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  const policy = {
9
9
  executor: {
10
- profile: "gpt6_sol_high",
10
+ profile: "gpt61_sol_high",
11
11
  timeoutSec: 1080,
12
12
  runMode: "fg",
13
13
  opusOversight: false,
@@ -15,7 +15,7 @@ const policy = {
15
15
  subcommand: "exec",
16
16
  },
17
17
  codex: {
18
- profile: "gpt6_sol_high",
18
+ profile: "gpt61_sol_high",
19
19
  timeoutSec: 1080,
20
20
  runMode: "fg",
21
21
  opusOversight: false,
@@ -31,7 +31,7 @@ const policy = {
31
31
  subcommand: "exec",
32
32
  },
33
33
  cleanup: {
34
- profile: "gpt6_sol_med",
34
+ profile: "gpt61_sol_med",
35
35
  timeoutSec: 540,
36
36
  runMode: "fg",
37
37
  opusOversight: false,
@@ -39,7 +39,7 @@ const policy = {
39
39
  subcommand: "exec",
40
40
  },
41
41
  deslop: {
42
- profile: "gpt6_sol_med",
42
+ profile: "gpt61_sol_med",
43
43
  timeoutSec: 540,
44
44
  runMode: "fg",
45
45
  opusOversight: false,
@@ -95,7 +95,7 @@ const policy = {
95
95
  subcommand: "exec",
96
96
  },
97
97
  "code-reviewer": {
98
- profile: "gpt6_sol_high",
98
+ profile: "gpt61_sol_high",
99
99
  timeoutSec: 1800,
100
100
  runMode: "bg",
101
101
  opusOversight: false,
@@ -103,7 +103,7 @@ const policy = {
103
103
  subcommand: "review",
104
104
  },
105
105
  "quality-reviewer": {
106
- profile: "gpt6_sol_high",
106
+ profile: "gpt61_sol_high",
107
107
  timeoutSec: 1800,
108
108
  runMode: "bg",
109
109
  opusOversight: false,
@@ -119,7 +119,7 @@ const policy = {
119
119
  subcommand: "review",
120
120
  },
121
121
  scientist: {
122
- profile: "gpt6_sol_high",
122
+ profile: "gpt61_sol_high",
123
123
  timeoutSec: 1440,
124
124
  runMode: "bg",
125
125
  opusOversight: false,
@@ -127,7 +127,7 @@ const policy = {
127
127
  subcommand: "exec",
128
128
  },
129
129
  "document-specialist": {
130
- profile: "gpt6_sol_high",
130
+ profile: "gpt61_sol_high",
131
131
  timeoutSec: 1440,
132
132
  runMode: "bg",
133
133
  opusOversight: false,
@@ -143,7 +143,7 @@ const policy = {
143
143
  subcommand: "exec",
144
144
  },
145
145
  verifier: {
146
- profile: "gpt6_sol_high",
146
+ profile: "gpt61_sol_high",
147
147
  timeoutSec: 1200,
148
148
  runMode: "fg",
149
149
  opusOversight: false,
@@ -151,7 +151,7 @@ const policy = {
151
151
  subcommand: "review",
152
152
  },
153
153
  "test-engineer": {
154
- profile: "gpt6_sol_high",
154
+ profile: "gpt61_sol_high",
155
155
  timeoutSec: 1200,
156
156
  runMode: "bg",
157
157
  opusOversight: false,
@@ -159,7 +159,7 @@ const policy = {
159
159
  subcommand: "exec",
160
160
  },
161
161
  "qa-tester": {
162
- profile: "gpt6_sol_high",
162
+ profile: "gpt61_sol_high",
163
163
  timeoutSec: 1200,
164
164
  runMode: "bg",
165
165
  opusOversight: false,
@@ -212,8 +212,8 @@ export const CANONICAL_CODEX_PROFILE_OVERRIDES = Object.freeze(
212
212
  new Set([
213
213
  "gpt6_luna_low",
214
214
  "gpt6_luna_high",
215
- "gpt6_sol_med",
216
- "gpt6_sol_high",
215
+ "gpt61_sol_med",
216
+ "gpt61_sol_high",
217
217
  "gpt6_astra_xhigh",
218
218
  "gpt6_astra_max",
219
219
  "gpt6_astra_ultra",
@@ -21,22 +21,25 @@ const EFFORT_BY_SUFFIX = {
21
21
  const CANONICAL_PROFILE_VALUES = Object.freeze({
22
22
  gpt6_luna_low: { model: "gpt-6-luna", effort: "low" },
23
23
  gpt6_luna_high: { model: "gpt-6-luna", effort: "high" },
24
- gpt6_sol_med: { model: "gpt-6-sol", effort: "medium" },
25
- gpt6_sol_high: { model: "gpt-6-sol", effort: "high" },
24
+ gpt61_sol_med: { model: "gpt-6.1-sol", effort: "medium" },
25
+ gpt61_sol_high: { model: "gpt-6.1-sol", effort: "high" },
26
26
  gpt6_astra_xhigh: { model: "gpt-6-astra", effort: "xhigh" },
27
27
  gpt6_astra_max: { model: "gpt-6-astra", effort: "max" },
28
28
  gpt6_astra_ultra: { model: "gpt-6-astra", effort: "ultra" },
29
29
  });
30
30
 
31
31
  // GPT-5.6 lane names map to the GPT-6 lane with the same effort. GPT-6 has no
32
- // Terra tier, so Terra lanes move to Sol.
32
+ // Terra tier, so Terra lanes move to Sol. GPT-6 Sol lane names map to GPT-6.1
33
+ // Sol with the same effort.
33
34
  const LEGACY_CANONICAL_PROFILE_ALIASES = Object.freeze({
34
35
  gpt56_sol_xhigh: "gpt6_astra_xhigh",
35
36
  gpt56_sol_max: "gpt6_astra_max",
36
37
  gpt56_sol_ultra: "gpt6_astra_ultra",
37
- gpt56_terra_high: "gpt6_sol_high",
38
- gpt56_terra_med: "gpt6_sol_med",
38
+ gpt56_terra_high: "gpt61_sol_high",
39
+ gpt56_terra_med: "gpt61_sol_med",
39
40
  gpt56_luna_low: "gpt6_luna_low",
41
+ gpt6_sol_high: "gpt61_sol_high",
42
+ gpt6_sol_med: "gpt61_sol_med",
40
43
  });
41
44
 
42
45
  export function normalizeCodexProfileName(profileName) {
@@ -65,7 +68,7 @@ function readProfileScalar(raw, key) {
65
68
  }
66
69
 
67
70
  /**
68
- * Resolve a Codex effort profile name (e.g. "gpt6_sol_high") to the `-c key=value`
71
+ * Resolve a Codex effort profile name (e.g. "gpt61_sol_high") to the `-c key=value`
69
72
  * config overrides that `codex exec` accepts, so the headless CLI lanes can
70
73
  * select a profile WITHOUT `codex exec --profile <name>`. codex 0.134+ rejects
71
74
  * `--profile X` whenever config.toml still contains an inline `[profiles.X]`
@@ -83,7 +86,7 @@ function readProfileScalar(raw, key) {
83
86
  *
84
87
  * @param {string} profileName
85
88
  * @param {{ codexHome?: string }} [opts]
86
- * @returns {string[]} e.g. ['model="gpt-6-sol"', 'model_reasoning_effort="high"']
89
+ * @returns {string[]} e.g. ['model="gpt-6.1-sol"', 'model_reasoning_effort="high"']
87
90
  */
88
91
  export function resolveCodexProfileConfigValues(profileName, opts = {}) {
89
92
  if (typeof profileName !== "string" || !profileName) {
@@ -8,9 +8,16 @@
8
8
  # 4차 FALLBACK_FAILED → 모두 실패 시 marker + stderr_log 경로 출력
9
9
 
10
10
  recover_codex_stdout() {
11
+ # 복구 진입 여부 플래그(전역, local 아님) — 아우터 no-op 가드가 읽는다.
12
+ # 0 = 진짜 stdout 존재(복구 미진입), 1 = stdout 이 애초에 비어 복구 진입함.
13
+ CODEX_STDOUT_WAS_RECOVERED=0
11
14
  if [[ -s "$STDOUT_LOG" || ! -s "$STDERR_LOG" ]]; then
12
15
  return 0
13
16
  fi
17
+ # 여기 도달 = stdout 이 애초에 비어있었다. 아래에서 STDOUT_LOG 에 채워지는 내용은
18
+ # 전부 stderr 에서 복구된 것이지 워커의 진짜 산출물이 아니다. 이 플래그로 아우터
19
+ # 가드가 "복구된 stderr 노이즈"를 성공 출력으로 오인하지 않게 한다(#result-verification).
20
+ CODEX_STDOUT_WAS_RECOVERED=1
14
21
 
15
22
  # 1차: codex marker
16
23
  sed 's/\r$//' "$STDERR_LOG" \
@@ -48,3 +55,15 @@ recover_codex_stdout() {
48
55
  echo "[tfx-route] 경고: codex stdout 비어있음, stderr 복구도 실패. 위 stderr_log 경로에서 raw codex 출력 확인 가능." >&2
49
56
  fi
50
57
  }
58
+
59
+ # ── MCP transport 채널 사망 판정 ──
60
+ # Codex MCP transport 채널이 실행 중 죽으면 codex exec 가 exit 0 + 빈 stdout 으로
61
+ # 끝나는 변형이 있다(래퍼 부재로 인한 pre-flight CODEX_MCP_TRANSPORT_EXIT_CODE=70
62
+ # 과 구분됨 — 이건 런타임 채널 사망). stderr 의 하드 크래시 서명으로 이 사망을
63
+ # 검출한다. 아우터 가드가 exit 0 이어도 결과를 실패로 승격하는 근거로 쓴다.
64
+ # 인자: [stderr_log] (미지정 시 $STDERR_LOG). 반환: 0=크래시 서명 있음, 1=없음.
65
+ codex_stdout_transport_crashed() {
66
+ local stderr_log="${1:-${STDERR_LOG:-}}"
67
+ [[ -n "$stderr_log" && -f "$stderr_log" ]] || return 1
68
+ grep -qE 'Transport channel closed|rmcp::transport worker quit with fatal' "$stderr_log" 2>/dev/null
69
+ }