triflux 9.7.7 → 9.7.9

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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "triflux",
11
11
  "description": "CLI-first multi-model orchestrator for Claude Code. Routes tasks to Codex, Gemini, and Claude CLIs with automatic triage, DAG-based parallel execution, headless psmux sessions, and cost-optimized routing. Includes 41 skills, HUD status bar, hook orchestrator, and shell-based CLI routing.",
12
- "version": "9.7.2",
12
+ "version": "9.7.9",
13
13
  "author": {
14
14
  "name": "tellang"
15
15
  },
@@ -30,5 +30,5 @@
30
30
  ]
31
31
  }
32
32
  ],
33
- "version": "9.7.2"
33
+ "version": "9.7.9"
34
34
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.7.2",
3
+ "version": "9.7.9",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "author": {
6
6
  "name": "tellang"
package/bin/triflux.mjs CHANGED
@@ -160,12 +160,12 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
160
160
  },
161
161
  },
162
162
  multi: {
163
- usage: "tfx multi [--dashboard-layout single|split-2col|split-3col|auto] <subcommand|task>",
163
+ usage: "tfx multi [--dashboard-layout lite|single|split-2col|split-3col|auto] <subcommand|task>",
164
164
  description: "멀티-CLI 팀 모드",
165
165
  options: [
166
166
  { name: "--dashboard", type: "boolean", description: "headless dashboard viewer 표시 (기본값: 켜짐)" },
167
167
  { name: "--no-dashboard", type: "boolean", description: "headless dashboard viewer 비활성화" },
168
- { name: "--dashboard-layout", type: "string", description: "dashboard viewer 레이아웃 선택: single|split-2col|split-3col|auto" },
168
+ { name: "--dashboard-layout", type: "string", description: "dashboard viewer 레이아웃 선택: lite|single|split-2col|split-3col|auto" },
169
169
  ],
170
170
  subcommands: {
171
171
  status: {
@@ -6,7 +6,6 @@
6
6
  // 파이프라인이 없으면 정상 종료를 허용한다.
7
7
 
8
8
  import { existsSync } from "node:fs";
9
- import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
10
9
 
11
10
  let getPipelineStateDbPath;
12
11
  let ensurePipelineTable;
@@ -22,7 +21,8 @@ try {
22
21
  process.exit(0);
23
22
  }
24
23
 
25
- const HUB_DB_PATH = getPipelineStateDbPath(PLUGIN_ROOT);
24
+ const PROJECT_ROOT = process.env.CLAUDE_CWD || process.cwd();
25
+ const HUB_DB_PATH = getPipelineStateDbPath(PROJECT_ROOT);
26
26
  const TERMINAL = new Set(["complete", "failed"]);
27
27
 
28
28
  async function checkActivePipelines() {
package/hub/team/ansi.mjs CHANGED
@@ -22,6 +22,7 @@ export function moveDown(n = 1) { return `${ESC}[${n}B`; }
22
22
  // ── 줄 제어 ──
23
23
  export const clearLine = `${ESC}[2K`;
24
24
  export const clearToEnd = `${ESC}[K`;
25
+ export const eraseBelow = `${ESC}[J`;
25
26
 
26
27
  // ── 색상 (triflux 디자인 시스템) ──
27
28
  export const RESET = `${ESC}[0m`;
@@ -16,6 +16,7 @@ function printStartUsage() {
16
16
  console.log(` 사용법: ${WHITE}tfx multi "작업 설명"${RESET}`);
17
17
  console.log(` ${WHITE}tfx multi --agents codex,gemini --lead claude "작업"${RESET}`);
18
18
  console.log(` ${WHITE}tfx multi --teammate-mode headless "작업"${RESET} ${DIM}(psmux 헤드리스, 기본)${RESET}`);
19
+ console.log(` ${WHITE}tfx multi --dashboard-layout lite "작업"${RESET} ${DIM}(dashboard-lite 기본 뷰)${RESET}`);
19
20
  console.log(` ${WHITE}tfx multi --dashboard-layout auto "작업"${RESET} ${DIM}(dashboard viewer 레이아웃 자동)${RESET}`);
20
21
  console.log(` ${WHITE}tfx multi --dashboard-anchor window "작업"${RESET} ${DIM}(dashboard anchor: window|tab, 기본 window)${RESET}`);
21
22
  console.log(` ${WHITE}tfx multi --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}`);
@@ -50,7 +50,7 @@ export function parseTeamArgs(args = []) {
50
50
  let timeoutSec = 300;
51
51
  let verbose = false;
52
52
  let dashboard = true;
53
- let dashboardLayout = "single";
53
+ let dashboardLayout = "lite";
54
54
  let dashboardSize = 0.40;
55
55
  let dashboardAnchor = "window";
56
56
  let mcpProfile = "";
@@ -11,6 +11,7 @@ export function renderTeamHelp() {
11
11
  ${WHITE}tfx multi --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}
12
12
  ${WHITE}tfx multi --layout 1xN "작업"${RESET} ${DIM}(세로 분할 컬럼)${RESET}
13
13
  ${WHITE}tfx multi --layout Nx1 "작업"${RESET} ${DIM}(가로 분할 스택)${RESET}
14
+ ${WHITE}tfx multi --dashboard-layout lite "작업"${RESET} ${DIM}(dashboard-lite 기본 뷰)${RESET}
14
15
  ${WHITE}tfx multi --dashboard-layout auto "작업"${RESET} ${DIM}(dashboard viewer 레이아웃 자동 결정)${RESET}
15
16
  ${WHITE}tfx multi --dashboard-size 0.4 "작업"${RESET} ${DIM}(대시보드 분할 비율 0.2~0.8, 기본 0.50)${RESET}
16
17
  ${WHITE}tfx multi --dashboard-anchor window "작업"${RESET} ${DIM}(대시보드 고정 위치: window|tab, 기본 window)${RESET}
@@ -0,0 +1,150 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import { psmuxExec } from "./psmux.mjs";
4
+ import {
5
+ detectMultiplexer,
6
+ focusWtPane,
7
+ hasWindowsTerminal,
8
+ resolveAttachCommand,
9
+ tmuxExec,
10
+ } from "./session.mjs";
11
+
12
+ function sanitizeWindowTitle(value, fallback = "triflux") {
13
+ const text = String(value || "").replace(/[\r\n]+/g, " ").trim();
14
+ return text || fallback;
15
+ }
16
+
17
+ function sanitizeSessionName(value) {
18
+ return String(value || "").replace(/[^a-zA-Z0-9_\-]/g, "") || "tfx-session";
19
+ }
20
+
21
+ function sanitizeWorkingDirectory(value) {
22
+ const text = String(value || "").replace(/[\r\n\x00-\x1f]/g, "").trim();
23
+ return text || process.cwd();
24
+ }
25
+
26
+ export function parseWorkerNumber(value) {
27
+ const text = String(value || "").trim();
28
+ const workerMatch = text.match(/^worker-(\d+)$/i);
29
+ if (workerMatch) return Number.parseInt(workerMatch[1], 10);
30
+ const paneMatch = text.match(/:(\d+)$/);
31
+ if (paneMatch) return Number.parseInt(paneMatch[1], 10);
32
+ return null;
33
+ }
34
+
35
+ export function decideDashboardOpenMode({ openAll = false, hasWtSession = !!process.env.WT_SESSION } = {}) {
36
+ if (openAll) return hasWtSession ? "tab" : "window";
37
+ return hasWtSession ? "split" : "window";
38
+ }
39
+
40
+ function spawnWindowsTerminal(spec, opts = {}) {
41
+ if (!hasWindowsTerminal()) return false;
42
+
43
+ const {
44
+ mode = "window",
45
+ title = "triflux",
46
+ cwd = process.cwd(),
47
+ split = { orientation: "H", size: 0.50 },
48
+ } = opts;
49
+
50
+ const safeTitle = sanitizeWindowTitle(title);
51
+ const safeCwd = sanitizeWorkingDirectory(cwd);
52
+ const orientation = split?.orientation === "V" ? "V" : "H";
53
+ const size = Number.isFinite(split?.size) ? Math.min(0.8, Math.max(0.2, split.size)) : 0.50;
54
+ const baseArgs = ["--profile", "triflux", "--title", safeTitle, "-d", safeCwd, "--", spec.command, ...spec.args];
55
+ const args = mode === "split"
56
+ ? ["-w", "0", "sp", `-${orientation}`, "-s", String(size), ...baseArgs]
57
+ : mode === "tab"
58
+ ? ["-w", "0", "nt", ...baseArgs]
59
+ : ["-w", "new", ...baseArgs];
60
+
61
+ const child = spawn("wt.exe", args, {
62
+ detached: true,
63
+ stdio: "ignore",
64
+ windowsHide: false,
65
+ });
66
+ child.unref();
67
+ return true;
68
+ }
69
+
70
+ export function focusManagedPane(target, opts = {}) {
71
+ const { teammateMode = "", layout = "1xN" } = opts;
72
+ const paneRef = String(target || "");
73
+
74
+ if (teammateMode === "wt" || paneRef.startsWith("wt:")) {
75
+ const paneIndex = parseWorkerNumber(paneRef);
76
+ return paneIndex != null && focusWtPane(paneIndex, { layout });
77
+ }
78
+
79
+ if (!paneRef) return false;
80
+ try {
81
+ if (detectMultiplexer() === "psmux") psmuxExec(["select-pane", "-t", paneRef]);
82
+ else tmuxExec(`select-pane -t ${paneRef}`);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ export function openHeadlessDashboardTarget(sessionName, opts = {}) {
90
+ const {
91
+ worker = null,
92
+ openAll = false,
93
+ cwd = process.cwd(),
94
+ title,
95
+ } = opts;
96
+
97
+ const safeSession = sanitizeSessionName(sessionName);
98
+ const workerNumber = worker == null ? null : parseWorkerNumber(worker);
99
+
100
+ if (!openAll && workerNumber != null) {
101
+ try {
102
+ psmuxExec(["select-pane", "-t", `${safeSession}:0.${workerNumber}`]);
103
+ } catch {}
104
+ }
105
+
106
+ return spawnWindowsTerminal(
107
+ { command: "psmux", args: ["attach-session", "-t", safeSession] },
108
+ {
109
+ mode: decideDashboardOpenMode({ openAll }),
110
+ title: title || (openAll ? `▲ ${safeSession}` : `▲ ${safeSession}:${workerNumber ?? "all"}`),
111
+ cwd,
112
+ },
113
+ );
114
+ }
115
+
116
+ export function openDashboardRuntimeTarget(runtime, opts = {}) {
117
+ const {
118
+ teammateMode = "",
119
+ sessionName = "",
120
+ targetPane = "",
121
+ layout = "1xN",
122
+ openAll = false,
123
+ cwd = process.cwd(),
124
+ title = "",
125
+ } = { ...runtime, ...opts };
126
+
127
+ if (teammateMode === "headless") {
128
+ return openHeadlessDashboardTarget(sessionName, {
129
+ worker: openAll ? null : targetPane,
130
+ openAll,
131
+ cwd,
132
+ title,
133
+ });
134
+ }
135
+
136
+ if ((teammateMode === "wt" || String(targetPane).startsWith("wt:")) && !openAll) {
137
+ return focusManagedPane(targetPane, { teammateMode: "wt", layout });
138
+ }
139
+
140
+ try {
141
+ if (!openAll && targetPane) focusManagedPane(targetPane, { teammateMode, layout });
142
+ return spawnWindowsTerminal(resolveAttachCommand(sessionName), {
143
+ mode: decideDashboardOpenMode({ openAll }),
144
+ title: title || `▲ ${sanitizeSessionName(sessionName)}`,
145
+ cwd,
146
+ });
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
@@ -1,4 +1,4 @@
1
- import { altScreenOff, altScreenOn, BG, bold, box, clearScreen, color, cursorHide, cursorHome, cursorShow, dim, FG, MOCHA, padRight, progressBar, statusBadge, stripAnsi, truncate, wcswidth } from "./ansi.mjs";
1
+ import { altScreenOff, altScreenOn, BG, bold, box, clearScreen, clearToEnd, color, cursorHide, cursorHome, cursorShow, dim, eraseBelow, FG, MOCHA, padRight, progressBar, statusBadge, stripAnsi, truncate, wcswidth } from "./ansi.mjs";
2
2
 
3
3
  const FALLBACK_COLUMNS = 100, FALLBACK_ROWS = 24;
4
4
  const VALID_TABS = new Set(["log", "detail", "files"]);
@@ -154,8 +154,11 @@ function buildDetail(workerName, worker, width, tab, helpVisible) {
154
154
  return frame(
155
155
  [
156
156
  bold("tui-lite"),
157
- "selectWorker(name) / setFocusTab(tab) / render()",
158
- "tabs: log | detail | files",
157
+ "j/k or arrows: worker selection",
158
+ "Enter: open selected worker",
159
+ "Shift+Enter: open all workers",
160
+ "l: tabs log/detail/files",
161
+ "1-9: direct select, q: quit",
159
162
  "toggleDetail(false) 로 상세 패널 숨김",
160
163
  ],
161
164
  width,
@@ -185,11 +188,14 @@ function buildDetail(workerName, worker, width, tab, helpVisible) {
185
188
  export function createLiteDashboard(opts = {}) {
186
189
  const {
187
190
  stream = process.stdout,
191
+ input = process.stdin,
188
192
  refreshMs = 1000,
189
193
  columns,
190
194
  rows,
191
195
  layout = "auto",
192
196
  forceTTY = false,
197
+ onOpenSelectedWorker,
198
+ onOpenAllWorkers,
193
199
  } = opts;
194
200
 
195
201
  const isTTY = forceTTY || !!stream?.isTTY;
@@ -203,6 +209,8 @@ export function createLiteDashboard(opts = {}) {
203
209
  let detailExpanded = true;
204
210
  let focusTab = "log";
205
211
  let helpVisible = false;
212
+ let inputAttached = false;
213
+ let rawModeEnabled = false;
206
214
 
207
215
  const write = (text) => { if (!closed) stream.write(text); };
208
216
  const workerNames = () => [...workers.keys()].sort();
@@ -210,6 +218,95 @@ export function createLiteDashboard(opts = {}) {
210
218
  const viewportRows = () => Math.max(10, rows || stream?.rows || process.stdout?.rows || FALLBACK_ROWS);
211
219
  const ensureSelection = (names) => { if (names.length && (!selectedWorker || !workers.has(selectedWorker))) selectedWorker = names[0]; };
212
220
 
221
+ function selectRelative(offset) {
222
+ const names = workerNames();
223
+ if (names.length === 0) return;
224
+ ensureSelection(names);
225
+ const idx = Math.max(0, names.indexOf(selectedWorker));
226
+ selectedWorker = names[(idx + offset + names.length) % names.length];
227
+ }
228
+
229
+ function triggerOpenSelected() {
230
+ if (typeof onOpenSelectedWorker !== "function" || !selectedWorker || !workers.has(selectedWorker)) return;
231
+ try {
232
+ const result = onOpenSelectedWorker(selectedWorker, workers.get(selectedWorker), new Map(workers));
233
+ if (result && typeof result.catch === "function") result.catch(() => {});
234
+ } catch {}
235
+ }
236
+
237
+ function triggerOpenAll() {
238
+ if (typeof onOpenAllWorkers !== "function") return;
239
+ try {
240
+ const result = onOpenAllWorkers(selectedWorker, workers.get(selectedWorker), new Map(workers));
241
+ if (result && typeof result.catch === "function") result.catch(() => {});
242
+ } catch {}
243
+ }
244
+
245
+ function handleInput(chunk) {
246
+ const key = String(chunk);
247
+ if (key === "\u0003") return;
248
+
249
+ if (helpVisible) {
250
+ helpVisible = false;
251
+ render();
252
+ return;
253
+ }
254
+
255
+ if (key === "j" || key === "\u001b[B") {
256
+ selectRelative(1);
257
+ render();
258
+ return;
259
+ }
260
+ if (key === "k" || key === "\u001b[A") {
261
+ selectRelative(-1);
262
+ render();
263
+ return;
264
+ }
265
+ if (key === "\r" || key === "\n") {
266
+ triggerOpenSelected();
267
+ return;
268
+ }
269
+ if (key === "\x1b[13;2u" || key === "\x1b[27;13;2~" || key === "\x1b\r" || key === "\x1b\n") {
270
+ triggerOpenAll();
271
+ return;
272
+ }
273
+ if (key === "l") {
274
+ const tabs = ["log", "detail", "files"];
275
+ focusTab = tabs[(tabs.indexOf(focusTab) + 1) % tabs.length];
276
+ render();
277
+ return;
278
+ }
279
+ if (key === "h" || key === "?") {
280
+ helpVisible = true;
281
+ render();
282
+ return;
283
+ }
284
+ if (key === "q") {
285
+ close();
286
+ return;
287
+ }
288
+ if (/^[1-9]$/.test(key)) {
289
+ const names = workerNames();
290
+ const target = names[Number.parseInt(key, 10) - 1];
291
+ if (target) {
292
+ selectedWorker = target;
293
+ render();
294
+ }
295
+ }
296
+ }
297
+
298
+ function attachInput() {
299
+ if (inputAttached) return;
300
+ if (!isTTY || !input?.isTTY || typeof input?.on !== "function") return;
301
+ inputAttached = true;
302
+ if (typeof input.setRawMode === "function") {
303
+ input.setRawMode(true);
304
+ rawModeEnabled = true;
305
+ }
306
+ if (typeof input.resume === "function") input.resume();
307
+ input.on("data", handleInput);
308
+ }
309
+
213
310
  function buildRows() {
214
311
  const names = workerNames();
215
312
  ensureSelection(names);
@@ -235,15 +332,22 @@ export function createLiteDashboard(opts = {}) {
235
332
 
236
333
  function render() {
237
334
  if (closed) return;
335
+ attachInput();
238
336
  frameCount++;
239
337
  const rowsOut = buildRows();
240
- if (isTTY) write(cursorHome + clearScreen + rowsOut.join("\n"));
241
- else write(`${rowsOut.join("\n")}\n`);
338
+ if (isTTY) {
339
+ const width = viewportColumns();
340
+ const padded = rowsOut.map((line) => padRight(String(line ?? ""), width) + clearToEnd);
341
+ write(cursorHome + padded.join("\n") + eraseBelow);
342
+ } else write(`${rowsOut.join("\n")}\n`);
242
343
  }
243
344
 
244
345
  function close() {
245
346
  if (closed) return;
246
347
  if (timer) clearInterval(timer);
348
+ if (inputAttached && typeof input?.off === "function") input.off("data", handleInput);
349
+ if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
350
+ if (inputAttached && typeof input?.pause === "function") input.pause();
247
351
  if (isTTY) write(cursorShow + altScreenOff);
248
352
  closed = true;
249
353
  }
@@ -9,6 +9,7 @@ import { join } from "node:path";
9
9
  import { tmpdir } from "node:os";
10
10
  import { createLogDashboard } from "./tui.mjs";
11
11
  import { createLiteDashboard } from "./tui-lite.mjs";
12
+ import { openHeadlessDashboardTarget } from "./dashboard-open.mjs";
12
13
  import { processHandoff } from "./handoff.mjs";
13
14
  import { statusBadge } from "./ansi.mjs";
14
15
 
@@ -53,6 +54,15 @@ const tui = tuiFactory({
53
54
  input: process.stdin,
54
55
  columns: process.stdout.columns || parseInt(process.env.COLUMNS, 10) || 120,
55
56
  layout: LAYOUT,
57
+ onOpenSelectedWorker: (workerName) => openHeadlessDashboardTarget(SESSION, {
58
+ worker: workerName,
59
+ openAll: false,
60
+ cwd: process.cwd(),
61
+ }),
62
+ onOpenAllWorkers: () => openHeadlessDashboardTarget(SESSION, {
63
+ openAll: true,
64
+ cwd: process.cwd(),
65
+ }),
56
66
  });
57
67
  const startTime = Date.now();
58
68
  tui.setStartTime(startTime);
@@ -221,6 +231,8 @@ function makeWorkerState(paneIdx) {
221
231
  handoff: null,
222
232
  progress: 0,
223
233
  activityAt: Date.now(),
234
+ title: "",
235
+ cli: "codex",
224
236
  };
225
237
  }
226
238
 
@@ -278,6 +290,8 @@ function ingest() {
278
290
  let cli = "codex";
279
291
  if (pane.title.includes("gemini") || pane.title.includes("🔵")) cli = "gemini";
280
292
  else if (pane.title.includes("claude") || pane.title.includes("🟠")) cli = "claude";
293
+ ws.title = pane.title;
294
+ ws.cli = cli;
281
295
 
282
296
  const resultData = checkResultFile(paneName);
283
297
  if (resultData?.processed && !resultData.processed.fallback) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.7.7",
3
+ "version": "9.7.9",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,10 +14,12 @@ import { homedir } from "node:os";
14
14
  import { fileURLToPath } from "node:url";
15
15
 
16
16
  import { readPreflightCache } from "./preflight-cache.mjs";
17
- import { checkCli, checkHub, detectCodexPlan } from "./lib/env-probe.mjs";
17
+ import { checkCli, checkHub, detectCodexAuthState } from "./lib/env-probe.mjs";
18
18
  import { SEARCH_SERVER_ORDER, MCP_SERVER_DOMAIN_TAGS } from "./lib/mcp-server-catalog.mjs";
19
19
 
20
20
  export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
21
+ const WARMUP_METADATA_FILE = ["state", "warmup-metadata.json"];
22
+ const AUTH_SENSITIVE_TARGETS = new Set(["codexSkills", "tierEnvironment", "searchEngines"]);
21
23
 
22
24
  export const CACHE_TARGETS = Object.freeze({
23
25
  codexSkills: Object.freeze({
@@ -83,6 +85,21 @@ export function resolveTargetPath(target, { cwd = process.cwd() } = {}) {
83
85
  return join(omcDir, ...spec.file);
84
86
  }
85
87
 
88
+ function resolveWarmupMetadataPath({ cwd = process.cwd() } = {}) {
89
+ const { omcDir } = resolveRootDirs(cwd);
90
+ return join(omcDir, ...WARMUP_METADATA_FILE);
91
+ }
92
+
93
+ function readWarmupMetadata(options = {}) {
94
+ const metadataPath = resolveWarmupMetadataPath(options);
95
+ if (!existsSync(metadataPath)) return null;
96
+ try {
97
+ return JSON.parse(readFileSync(metadataPath, "utf8"));
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
86
103
  function resolveTtlMs(target, options = {}) {
87
104
  if (Number.isFinite(options.ttlByTarget?.[target])) {
88
105
  return Math.max(0, Math.trunc(options.ttlByTarget[target]));
@@ -188,6 +205,7 @@ export function probeTierEnvironment(options = {}) {
188
205
  const homeDir = resolveHomeDir(options.homeDir);
189
206
  const preflight = options.preflight ?? readPreflightCache();
190
207
  const execSyncFn = options.execSyncFn || execSync;
208
+ const codexAuth = preflight?.codex_plan ?? detectCodexAuthState({ homeDir });
191
209
 
192
210
  const codexCheck = preflight?.codex || checkCli("codex", { execSyncFn });
193
211
  const geminiCheck = preflight?.gemini || checkCli("gemini", { execSyncFn });
@@ -198,8 +216,6 @@ export function probeTierEnvironment(options = {}) {
198
216
  pollAttempts: options.hubRestart === true ? 8 : 0,
199
217
  execSyncFn,
200
218
  });
201
- const codexPlan = preflight?.codex_plan || detectCodexPlan({ homeDir });
202
-
203
219
  const checks = {
204
220
  psmux: false,
205
221
  hub: !!hubCheck?.ok,
@@ -241,7 +257,9 @@ export function probeTierEnvironment(options = {}) {
241
257
  tier,
242
258
  checks,
243
259
  available_agents: agents,
244
- codex_plan: codexPlan,
260
+ codex_plan: codexAuth.source == null
261
+ ? { plan: codexAuth.plan }
262
+ : { plan: codexAuth.plan, source: codexAuth.source },
245
263
  source: {
246
264
  preflight: !!preflight,
247
265
  home_dir: homeDir,
@@ -250,6 +268,21 @@ export function probeTierEnvironment(options = {}) {
250
268
  };
251
269
  }
252
270
 
271
+ function getCodexAuthFingerprint(options = {}) {
272
+ if (typeof options.preflight?.codex_plan?.fingerprint === "string") {
273
+ return options.preflight.codex_plan.fingerprint;
274
+ }
275
+ return detectCodexAuthState({ homeDir: resolveHomeDir(options.homeDir) }).fingerprint;
276
+ }
277
+
278
+ function hasAuthFingerprintChanged(target, options = {}) {
279
+ if (!AUTH_SENSITIVE_TARGETS.has(target)) return false;
280
+ const nextFingerprint = getCodexAuthFingerprint(options);
281
+ const previousFingerprint = readWarmupMetadata(options)?.codex_auth_fingerprint || null;
282
+ if (previousFingerprint === null) return false;
283
+ return previousFingerprint !== nextFingerprint;
284
+ }
285
+
253
286
  export function extractProjectMeta(options = {}) {
254
287
  const cwd = options.cwd || process.cwd();
255
288
  const execSyncFn = options.execSyncFn || execSync;
@@ -422,7 +455,7 @@ export function checkSearchEngines(options = {}) {
422
455
 
423
456
  function buildTarget(target, options = {}) {
424
457
  const filePath = resolveTargetPath(target, options);
425
- if (!options.force && isFresh(target, options)) {
458
+ if (!options.force && isFresh(target, options) && !hasAuthFingerprintChanged(target, options)) {
426
459
  return { target, status: "skipped", file: filePath, reason: "fresh" };
427
460
  }
428
461
 
@@ -476,6 +509,15 @@ export function buildAll(options = {}) {
476
509
  const built = results.filter((result) => result.status === "built").length;
477
510
  const skipped = results.filter((result) => result.status === "skipped").length;
478
511
  const failed = results.filter((result) => result.status === "failed").length;
512
+ const authFingerprint = getCodexAuthFingerprint(options);
513
+
514
+ if (failed === 0) {
515
+ writeJSON(resolveWarmupMetadataPath(options), {
516
+ updated_at: new Date(options.now ?? Date.now()).toISOString(),
517
+ codex_auth_fingerprint: authFingerprint,
518
+ targets,
519
+ });
520
+ }
479
521
 
480
522
  return {
481
523
  ok: failed === 0,
@@ -3,6 +3,7 @@ import { join, dirname } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { execSync, spawn } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { createHash } from "node:crypto";
6
7
 
7
8
  const DEFAULT_STATUS_URL = "http://127.0.0.1:27888/status";
8
9
  const _sab = new Int32Array(new SharedArrayBuffer(4));
@@ -48,29 +49,58 @@ export function checkCli(name, { execSyncFn = execSync } = {}) {
48
49
  }
49
50
  }
50
51
 
51
- export function detectCodexPlan({
52
+ export function detectCodexAuthState({
52
53
  homeDir = homedir(),
53
54
  existsSyncFn = existsSync,
54
55
  readFileSyncFn = readFileSync,
55
56
  } = {}) {
56
57
  try {
57
58
  const authPath = join(homeDir, ".codex", "auth.json");
58
- if (!existsSyncFn(authPath)) return { plan: "unknown", source: "no_auth" };
59
+ if (!existsSyncFn(authPath)) return { plan: "unknown", source: "no_auth", fingerprint: "no_auth" };
59
60
 
60
61
  const auth = JSON.parse(readFileSyncFn(authPath, "utf8"));
61
- if (auth.auth_mode !== "chatgpt") return { plan: "api", source: "api_key" };
62
+ if (auth.auth_mode !== "chatgpt") {
63
+ const fingerprint = createHash("sha256")
64
+ .update(JSON.stringify({
65
+ auth_mode: auth.auth_mode || "api_key",
66
+ has_api_key: Boolean(auth.api_key || auth.apiKey),
67
+ }))
68
+ .digest("hex");
69
+ return { plan: "api", source: "api_key", fingerprint };
70
+ }
62
71
 
63
72
  const token = auth.tokens?.id_token || auth.tokens?.access_token;
64
- if (!token) return { plan: "unknown", source: "no_token" };
73
+ if (!token) {
74
+ return {
75
+ plan: "unknown",
76
+ source: "no_token",
77
+ fingerprint: createHash("sha256")
78
+ .update(JSON.stringify({ auth_mode: auth.auth_mode || "chatgpt", token: null }))
79
+ .digest("hex"),
80
+ };
81
+ }
65
82
 
66
83
  const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
67
84
  const plan = payload?.["https://api.openai.com/auth"]?.chatgpt_plan_type || "unknown";
68
- return { plan, source: "jwt" };
85
+ const fingerprint = createHash("sha256")
86
+ .update(JSON.stringify({
87
+ auth_mode: auth.auth_mode || "chatgpt",
88
+ plan,
89
+ sub: payload?.sub || null,
90
+ exp: payload?.exp || null,
91
+ }))
92
+ .digest("hex");
93
+ return { plan, source: "jwt", fingerprint };
69
94
  } catch {
70
- return { plan: "unknown", source: "error" };
95
+ return { plan: "unknown", source: "error", fingerprint: "error" };
71
96
  }
72
97
  }
73
98
 
99
+ export function detectCodexPlan(options = {}) {
100
+ const { plan, source } = detectCodexAuthState(options);
101
+ return { plan, source };
102
+ }
103
+
74
104
  export function checkHub({
75
105
  pkgRoot = DEFAULT_PKG_ROOT,
76
106
  statusUrl = DEFAULT_STATUS_URL,