triflux 10.63.0 → 10.65.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.
@@ -33,6 +33,10 @@ macOS의 iTerm2에서 로컬 tmux 세션을 `start`하면 리드가 있는 창
33
33
 
34
34
  새 세션 이름은 `<월>.<일> <주제>`로 짓는다. `start --name`을 생략하면 자동으로 만들고 `nameGenerated: true`를 보고한다. `--resume` 또는 `--resume-last`는 `--name`을 명시한 경우에만 이름을 적용한다. 승계 세션에는 기존 이름 뒤에 ` 2`, 이후 ` 3`을 붙인다. 이름 적용을 요청한 경우 결과의 `nameApplied: false`는 미적용을 뜻한다. agy의 `namePending: true`는 첫 일반 ask에서 적용할 이름이 보류됐다는 뜻이다. Codex `start`는 `/rename` 제출을 입력창이 빌 때까지 확인하고, 이름으로 찾은 thread UUID를 `threadId`로 돌려주며 tmux 세션 옵션 `@tfx_codex_thread`에 남긴다. 로컬 Codex `--resume-last`는 Codex가 고른 thread를 사후에 찾는다. 띄운 뒤 공유 app-server daemon에 로드된 같은 cwd의 thread 중 rollout에 그 시각 이후의 resume 기록(`thread_settings_applied`)이 생긴 것이 정확히 하나이고 1.5초 뒤에도 같을 때만 `threadId`로 쓴다. 이 확인은 5초 안에 끝난다. TUI가 daemon을 쓰지 않거나 같은 cwd에서 동시에 resume이 일어나면 `null`이다. 기존 Codex UDS thread는 `tfx-live rename --cli codex --transport uds --thread ID --name "<이름>"`으로 이름을 바꾼다.
35
35
 
36
+ Codex 폴더 신뢰 화면이나 Claude 프로젝트 MCP 승인 화면이 뜨면 `start`는 화면에 답하지 않고 `ready: false`, `blockedBy`(`codex-trust-folder` 또는 `claude-mcp-approval`), 할 일을 적은 `warning`을 반환한다. 세션은 남아 있으므로 tmux 세션에 붙어 직접 고른 뒤 지시를 보낸다.
37
+
38
+ Claude, agy, 예전 Codex 문구(`Do you trust the contents of this directory`)의 폴더 신뢰 화면은 기본으로 자동 승인한다. `start --no-auto-trust`를 주면 이 화면에도 키를 보내지 않고 같은 형식으로 `blockedBy`(`claude-trust-folder`, `agy-trust-folder`, `codex-trust-folder`)를 반환한다. `--resume`, `--resume-last`, `--remote`에도 똑같이 적용된다.
39
+
36
40
  ## agy 세션
37
41
 
38
42
  ```bash
@@ -105,19 +109,20 @@ Claude와 Codex는 네이티브 goal을 우선 쓰고, goal-driven은 agy처럼
105
109
 
106
110
  ### 컨텍스트 판단
107
111
 
108
- 분모는 공식 모델 창이고 실행 한도가 더 작으면 그 한도 아래에서 운영한다. 아래 비율은 공식 권고가 아닌 **[추론] 운영값**이다. 모델 문서가 바뀌면 한도도 갱신한다.
112
+ Claude의 분모는 공식 모델 창과 실행 한도 중 작은 값이다. Codex의 분모는 rollout이 보고한 실행 창이고, 없을 때만 공식 창이다. 아래 비율은 공식 권고가 아닌 **[추론] 운영값**이다. 모델 문서가 바뀌면 한도도 갱신한다.
109
113
 
110
114
  | CLI | 준비와 경고 | compact | 신규 배정 중지·handoff | 전송 거부 |
111
115
  | --- | ---: | ---: | ---: | ---: |
112
116
  | Claude Code 1M 모델 | 60% | 75% | 85% | 90% |
113
- | Codex | 15% | 18% | 20% | 22% |
117
+ | Codex rollout 실행 창 | 60% | - | - | 85% |
118
+ | Codex 실행 창 없음(1.05M 분모) | 15% | 18% | 20% | 22% |
114
119
  | agy | 12% | 없음 | 15% | 18% |
115
120
 
116
- Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
121
+ Claude는 85% 도달 시 바로 handoff를 지시한다. Codex 실행 창 행의 compact와 handoff 비율은 아직 정하지 않았다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
117
122
 
118
- 결과에는 `estimatedContextTokens`, `contextLimitTokens`, `contextLimitSource`, `contextPct`가 포함된다. Claude 실행 한도는 `executionContextLimitTokens`로 별도 보고하며 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`이면 200,000이다. 판정에는 공식 창과 실행 한도 중 작은 값을 쓴다. 한도를 모르면 비율은 `null`로 두고 경고만 한다. 사용량이 없는 `probe`는 `context: null`이다. 컨텍스트를 확인하려면 직접 `tfx-live probe`를 실행한다. `interrupt`, 자동 `ask`의 daemon probe, `stop --short`는 transcript를 읽지 않는다.
123
+ 결과에는 `estimatedContextTokens`, `contextLimitTokens`, `contextLimitSource`, `contextPct`가 포함된다. Claude 실행 한도는 `executionContextLimitTokens`로 별도 보고하며 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`이면 200,000이다. Codex는 rollout `token_count`의 `model_context_window`를 `executionContextLimitTokens`로 보고한다. 이 값이 있으면 모델 표에 없는 모델이라도 그 창으로 판정하고, 없으면 1,050,000 표 값을 분모로 쓴다. 한도를 모르면 비율은 `null`로 두고 경고만 한다. 사용량이 없는 `probe`는 `context: null`이다. 컨텍스트를 확인하려면 직접 `tfx-live probe`를 실행한다. `interrupt`, 자동 `ask`의 daemon probe, `stop --short`는 transcript를 읽지 않는다.
119
124
 
120
- `ask`의 `--warn-context-pct`와 `--max-context-pct`는 경고·거부 비율을 덮어쓰며 0이면 해당 가드를 끈다. 기본값은 표의 경고·전송 거부 열이다. 거부 결과는 `status: "failed"`, `done: false`, `context-limit`와 컨텍스트 필드를 돌려준다. 자세한 근거와 실측은 [컨텍스트 정책 연구](../../docs/research/tfx-live-context-policy-2026-10.md)에 둔다.
125
+ `ask`의 `--warn-context-pct`와 `--max-context-pct`는 경고·거부 비율을 덮어쓰며 0이면 해당 가드를 끈다. 기본값은 표의 경고·전송 거부 열이다. Codex는 `executionContextLimitTokens`가 있으면 실행 창 행을, 없으면 1.05M 분모 행을 쓴다. 거부 결과는 `status: "failed"`, `done: false`, `context-limit`, 컨텍스트 필드와 적용한 거부 비율 `thresholdPct`를 돌려준다. 경고의 `thresholdPct`는 적용한 경고 비율이다. 자세한 근거와 실측은 [컨텍스트 정책 연구](../../docs/research/tfx-live-context-policy-2026-10.md)에 둔다.
121
126
 
122
127
  ### 수동 compact와 종료
123
128
 
package/bin/tfx-live.mjs CHANGED
@@ -53,7 +53,6 @@ import {
53
53
  validateHost as validateRemoteHost,
54
54
  } from "../hub/team/remote-session.mjs";
55
55
  import {
56
- CONTEXT_THRESHOLDS,
57
56
  contextGuard,
58
57
  findCodexRollout,
59
58
  modelContext,
@@ -90,6 +89,7 @@ const BOOLEAN_FLAGS = new Set([
90
89
  "no-wait",
91
90
  "no-relay-tag",
92
91
  "sandbox",
92
+ "no-auto-trust",
93
93
  ]);
94
94
  const PEER_HOP_DONE_MARKER = "<<<TFX_PEER_HOP_DONE>>>";
95
95
  // uds-fallback diagnostics land here, written async so a failed daemon attach
@@ -101,13 +101,13 @@ const BUG_REPORT_DIR =
101
101
  function usage(command) {
102
102
  const lines = [
103
103
  "Usage:",
104
- " tfx-live start --session NAME [--name NAME] [--group NAME] [--cli codex|claude|agy] [--sandbox] [--unset-env PATTERNS] [--model ID] [--effort TIER] [--cwd DIR] [--remote HOST] [--resume ID] [--resume-last 1] [--ready-timeout 30] [--poll-interval 1500]",
104
+ " tfx-live start --session NAME [--name NAME] [--group NAME] [--cli codex|claude|agy] [--sandbox] [--no-auto-trust] [--unset-env PATTERNS] [--model ID] [--effort TIER] [--cwd DIR] [--remote HOST] [--resume ID] [--resume-last 1] [--ready-timeout 30] [--poll-interval 1500]",
105
105
  " tfx-live ask --session NAME[:WINDOW.PANE] --prompt TEXT [--cli codex|claude|agy] [--if-busy wait|fail|interrupt|queue] [--busy-timeout 60] [--timeout 60] [--remote HOST] [--settle 1500] [--poll-interval 1500]",
106
106
  " tfx-live ask --cli codex [--transport queue|tmux] (--session NAME[:WINDOW.PANE] | --thread UUID) --prompt TEXT [--from NAME] [--timeout 60] [--no-wait]",
107
107
  " Codex ask defaults to the app-server queue (thread/queue/add, `codex queue --message` only when add is unavailable; delivered when the TUI picks it up); tmux paste is the reported fallback and the path for slash commands.",
108
108
  " tfx-live ask --cli codex --transport uds --thread ID|auto --prompt TEXT [--codex-socket PATH|default] [--cwd DIR] [--if-busy wait|fail|steer] [--busy-timeout 60] [--timeout 60] [--max-turn SECONDS]",
109
109
  " tfx-live ask --transport uds|auto (--short SHORT | --session-id ID) --prompt TEXT [--config-dir DIR] [--bridge ABS] [--session NAME (auto fallback)] [--timeout 60]",
110
- " ask options: --no-wait --no-relay-tag --warn-context-pct N --max-context-pct N (0 disables; Claude 60/90, Codex 15/22).",
110
+ " ask options: --no-wait --no-relay-tag --warn-context-pct N --max-context-pct N (0 disables; Claude 60/90, Codex 60/85 of rollout window or 15/22 of 1.05M).",
111
111
  " tfx-live compact --session NAME [--cli claude|codex] [--instructions TEXT] [--then PROMPT] [--no-wait] [--if-busy fail|wait] [--timeout 60]",
112
112
  " tfx-live goal set --session NAME [--cli claude|codex] --condition TEXT [--budget N]",
113
113
  " tfx-live goal status --session NAME [--cli claude|codex]",
@@ -220,8 +220,10 @@ function integerFlag(flags, name, defaultValue) {
220
220
  return value;
221
221
  }
222
222
 
223
- function contextPctFlag(flags, name, defaultValue) {
224
- const value = flags[name] === undefined ? defaultValue : Number(flags[name]);
223
+ // 기본값은 contextGuard 가 읽은 실행 창에 맞춰 고른다.
224
+ function contextPctFlag(flags, name) {
225
+ if (flags[name] === undefined) return undefined;
226
+ const value = Number(flags[name]);
225
227
  if (!Number.isFinite(value) || value < 0 || value > 100)
226
228
  throw new Error(`--${name} must be between 0 and 100`);
227
229
  return value;
@@ -230,16 +232,8 @@ function contextPctFlag(flags, name, defaultValue) {
230
232
  function contextOpts(flags, cli) {
231
233
  if (cli === "agy") return {};
232
234
  return {
233
- warnContextPct: contextPctFlag(
234
- flags,
235
- "warn-context-pct",
236
- CONTEXT_THRESHOLDS[cli].warnContextPct,
237
- ),
238
- maxContextPct: contextPctFlag(
239
- flags,
240
- "max-context-pct",
241
- CONTEXT_THRESHOLDS[cli].maxContextPct,
242
- ),
235
+ warnContextPct: contextPctFlag(flags, "warn-context-pct"),
236
+ maxContextPct: contextPctFlag(flags, "max-context-pct"),
243
237
  };
244
238
  }
245
239
 
@@ -1770,6 +1764,72 @@ function isClaudeExternalImportsPrompt(text) {
1770
1764
  );
1771
1765
  }
1772
1766
 
1767
+ // 승인 화면에서는 마지막 선택 글리프 줄이 메뉴 항목이다. 문구를 인용한 대화라면
1768
+ // 그 아래 입력창이 마지막 글리프 줄이 되므로 걸러진다. 위쪽 문구는 테두리와
1769
+ // 줄바꿈이 끊어도 맞도록 공백 하나로 접는다.
1770
+ function splitAtLastSelector(text, glyph) {
1771
+ const lines = stripAnsi(text)
1772
+ .split("\n")
1773
+ .map((line) => line.replace(/^[\s│┃]+/, ""));
1774
+ const index = lines.findLastIndex((line) => line.startsWith(glyph));
1775
+ if (index < 0) return null;
1776
+ const isRule = (line = "") => /^─{3,}/.test(line);
1777
+ return {
1778
+ above: lines.slice(0, index).join(" ").replace(/\s+/g, " "),
1779
+ selected: lines[index],
1780
+ // Claude 입력창은 위아래 가로줄 상자다. 여러 줄 입력이면 아래 가로줄이 몇 줄 뒤에 온다.
1781
+ inComposerBox:
1782
+ isRule(lines[index - 1]) && lines.slice(index + 1).some(isRule),
1783
+ };
1784
+ }
1785
+
1786
+ function isCodexTrustFolderPrompt(text) {
1787
+ const menu = splitAtLastSelector(text, "›");
1788
+ return (
1789
+ Boolean(menu) &&
1790
+ /trust this folder\?/i.test(menu.above) &&
1791
+ /^›\s*(?:\d+\.\s*)?(?:trust and continue|open restricted)/i.test(
1792
+ menu.selected,
1793
+ )
1794
+ );
1795
+ }
1796
+
1797
+ // 신뢰 화면 판정. 자동 승인과 --no-auto-trust 차단이 같이 쓴다. 문구만 보면 재개 대화의 인용에도 걸리므로 메뉴 구조까지 본다.
1798
+ // 항목 문구는 Claude 2.1.296 바이너리, 0.162.1 에 없는 예전 Codex 문구는 dismissCodexTrustPrompt 기준.
1799
+ function isClaudeTrustMenu(text) {
1800
+ const menu = splitAtLastSelector(text, "❯");
1801
+ return (
1802
+ Boolean(menu) &&
1803
+ !menu.inComposerBox &&
1804
+ isClaudeTrustPrompt(menu.above) &&
1805
+ /^❯\s*(?:\d+\.\s*)?(?:yes, i trust this folder|no, exit|no, continue without these permissions)/i.test(
1806
+ menu.selected,
1807
+ )
1808
+ );
1809
+ }
1810
+
1811
+ function isLegacyCodexTrustMenu(text) {
1812
+ const menu = splitAtLastSelector(text, "›");
1813
+ return (
1814
+ Boolean(menu) &&
1815
+ isCodexTrustPrompt(menu.above) &&
1816
+ /^›\s*(?:\d+\.\s*)?(?:yes, continue|no, quit)/i.test(menu.selected)
1817
+ );
1818
+ }
1819
+
1820
+ // 다중 선택 항목은 한 글자 체크박스로 그린다. 바이너리 상수: [ ] [×] [✔] [√] ☐ ☒, 라디오 ( ) (*) ◯ ◉.
1821
+ function isClaudeMcpApprovalPrompt(text) {
1822
+ const menu = splitAtLastSelector(text, "❯");
1823
+ return (
1824
+ Boolean(menu) &&
1825
+ !menu.inComposerBox &&
1826
+ /new MCP servers? found in this project/i.test(menu.above) &&
1827
+ /^❯\s*(?:\d+\.\s*)?(?:use this|continue without using this MCP server|enable selected|\[.\]|\(.\)|[☐☒◯◉])/iu.test(
1828
+ menu.selected,
1829
+ )
1830
+ );
1831
+ }
1832
+
1773
1833
  function isClaudeTourPrompt(text) {
1774
1834
  // Claude's post-update welcome/tour screen ("Take the tour" / "Skip for now").
1775
1835
  // Claude has no codex-style "Update available / Skip until next version" menu
@@ -1805,7 +1865,7 @@ function isSafeSkipLine(line) {
1805
1865
 
1806
1866
  async function dismissCodexTrustPrompt(remote, session) {
1807
1867
  let raw = await captureVisible(remote, session);
1808
- if (!isCodexTrustPrompt(raw)) {
1868
+ if (!isLegacyCodexTrustMenu(raw)) {
1809
1869
  return { dismissed: false, raw };
1810
1870
  }
1811
1871
 
@@ -1881,7 +1941,7 @@ async function dismissCodexDaemonSettingsPrompt(remote, session) {
1881
1941
 
1882
1942
  async function dismissClaudeTrustPrompt(remote, session) {
1883
1943
  let raw = await captureVisible(remote, session);
1884
- if (!isClaudeTrustPrompt(raw)) {
1944
+ if (!isClaudeTrustMenu(raw)) {
1885
1945
  return { dismissed: false, raw };
1886
1946
  }
1887
1947
 
@@ -1961,6 +2021,16 @@ function isAgyTrustPrompt(text) {
1961
2021
  return /Do you trust the contents of this project\?/.test(stripAnsi(text));
1962
2022
  }
1963
2023
 
2024
+ // --no-auto-trust 차단에만 쓴다. agy 1.3.3 바이너리에서 "No, exit" 가 신뢰 화면 항목인지 확정하지 못해 자동 승인과 isReady 는 문구 판정을 유지한다.
2025
+ function isAgyTrustMenu(text) {
2026
+ const menu = splitAtLastSelector(text, ">");
2027
+ return (
2028
+ Boolean(menu) &&
2029
+ isAgyTrustPrompt(menu.above) &&
2030
+ /^>\s*(?:yes, i trust this folder|no, exit)/i.test(menu.selected)
2031
+ );
2032
+ }
2033
+
1964
2034
  function isAgyQueuedCapture(text) {
1965
2035
  return /Press up to edit queued messages/i.test(stripAnsi(text));
1966
2036
  }
@@ -2010,8 +2080,11 @@ const ADAPTERS = {
2010
2080
  startupScreens: [
2011
2081
  {
2012
2082
  name: "trust",
2013
- isPresent: isCodexTrustPrompt,
2083
+ blockedBy: "codex-trust-folder",
2084
+ isPresent: isLegacyCodexTrustMenu,
2014
2085
  dismiss: dismissCodexTrustPrompt,
2086
+ warning:
2087
+ "Codex is asking whether to trust this folder; attach to the tmux session and answer it yourself",
2015
2088
  },
2016
2089
  {
2017
2090
  name: "update",
@@ -2024,6 +2097,15 @@ const ADAPTERS = {
2024
2097
  dismiss: dismissCodexDaemonSettingsPrompt,
2025
2098
  },
2026
2099
  ],
2100
+ // 사람의 보안 판단이 필요한 화면이라 고르지 않고 start 를 ready:false 로 끝낸다.
2101
+ blockingScreens: [
2102
+ {
2103
+ name: "codex-trust-folder",
2104
+ isPresent: isCodexTrustFolderPrompt,
2105
+ warning:
2106
+ "Codex is asking whether to trust this folder; attach to the tmux session and answer it yourself",
2107
+ },
2108
+ ],
2027
2109
  },
2028
2110
  claude: {
2029
2111
  cli: "claude",
@@ -2067,8 +2149,11 @@ const ADAPTERS = {
2067
2149
  startupScreens: [
2068
2150
  {
2069
2151
  name: "trust",
2070
- isPresent: isClaudeTrustPrompt,
2152
+ blockedBy: "claude-trust-folder",
2153
+ isPresent: isClaudeTrustMenu,
2071
2154
  dismiss: dismissClaudeTrustPrompt,
2155
+ warning:
2156
+ "Claude is asking whether to trust this folder; attach to the tmux session and answer it yourself",
2072
2157
  },
2073
2158
  {
2074
2159
  name: "external-imports",
@@ -2081,6 +2166,14 @@ const ADAPTERS = {
2081
2166
  dismiss: dismissClaudeTourPrompt,
2082
2167
  },
2083
2168
  ],
2169
+ blockingScreens: [
2170
+ {
2171
+ name: "claude-mcp-approval",
2172
+ isPresent: isClaudeMcpApprovalPrompt,
2173
+ warning:
2174
+ "Claude is asking to approve project MCP servers; attach to the tmux session and answer it yourself",
2175
+ },
2176
+ ],
2084
2177
  },
2085
2178
  agy: {
2086
2179
  cli: "agy",
@@ -2096,8 +2189,12 @@ const ADAPTERS = {
2096
2189
  startupScreens: [
2097
2190
  {
2098
2191
  name: "trust",
2192
+ blockedBy: "agy-trust-folder",
2193
+ isBlocking: isAgyTrustMenu,
2099
2194
  isPresent: isAgyTrustPrompt,
2100
2195
  dismiss: dismissAgyTrustPrompt,
2196
+ warning:
2197
+ "agy is asking whether to trust this folder; attach to the tmux session and answer it yourself",
2101
2198
  },
2102
2199
  ],
2103
2200
  },
@@ -2204,6 +2301,17 @@ async function waitForReady(
2204
2301
 
2205
2302
  while (Date.now() - startedAt < timeoutMs) {
2206
2303
  raw = await captureVisible(remote, session);
2304
+ const blocking = adapter.blockingScreens?.find((screen) =>
2305
+ screen.isPresent(raw),
2306
+ );
2307
+ if (blocking) {
2308
+ return {
2309
+ ready: false,
2310
+ raw,
2311
+ blockedBy: blocking.name,
2312
+ warning: blocking.warning,
2313
+ };
2314
+ }
2207
2315
  if (isStartupScreen(adapter, raw)) {
2208
2316
  await dismissStartupScreens(adapter, remote, session);
2209
2317
  await sleep(pollIntervalMs);
@@ -2630,7 +2738,25 @@ end tell`;
2630
2738
  });
2631
2739
  }
2632
2740
 
2633
- async function doStart(adapter, opts) {
2741
+ async function doStart(launchAdapter, opts) {
2742
+ // --no-auto-trust 면 신뢰 화면을 고르지 않고 승인 화면과 같이 ready:false 로 끝낸다.
2743
+ const trustScreens = opts.noAutoTrust
2744
+ ? launchAdapter.startupScreens.filter((screen) => screen.blockedBy)
2745
+ : [];
2746
+ const adapter = {
2747
+ ...launchAdapter,
2748
+ startupScreens: launchAdapter.startupScreens.filter(
2749
+ (screen) => !trustScreens.includes(screen),
2750
+ ),
2751
+ blockingScreens: [
2752
+ ...trustScreens.map((screen) => ({
2753
+ name: screen.blockedBy,
2754
+ isPresent: screen.isBlocking ?? screen.isPresent,
2755
+ warning: screen.warning,
2756
+ })),
2757
+ ...(launchAdapter.blockingScreens ?? []),
2758
+ ],
2759
+ };
2634
2760
  const {
2635
2761
  session,
2636
2762
  remote,
@@ -2706,7 +2832,7 @@ async function doStart(adapter, opts) {
2706
2832
  await runTmux(remote, ["send-keys", "-t", session, ...launchKeys]);
2707
2833
  await dismissStartupScreens(adapter, remote, session);
2708
2834
 
2709
- const { ready, raw } = await waitForReady(
2835
+ const { ready, raw, blockedBy, warning } = await waitForReady(
2710
2836
  adapter,
2711
2837
  remote,
2712
2838
  session,
@@ -2886,6 +3012,7 @@ end tell`;
2886
3012
  resumed,
2887
3013
  resumeTarget,
2888
3014
  ready,
3015
+ ...(blockedBy ? { blockedBy, warning } : {}),
2889
3016
  name,
2890
3017
  nameGenerated: !resumed && opts.name === undefined,
2891
3018
  nameApplied,
@@ -3988,6 +4115,7 @@ function startOpts(flags) {
3988
4115
  name: flags.name,
3989
4116
  group: flags.group,
3990
4117
  sandbox: Object.hasOwn(flags, "sandbox"),
4118
+ noAutoTrust: Object.hasOwn(flags, "no-auto-trust"),
3991
4119
  unsetEnv,
3992
4120
  readyTimeoutMs: secondsFlag(
3993
4121
  flags,
@@ -23,14 +23,21 @@ const MODEL_CONTEXT = [
23
23
  },
24
24
  ];
25
25
 
26
- export const CONTEXT_THRESHOLDS = {
26
+ const CONTEXT_THRESHOLDS = {
27
27
  claude: { warnContextPct: 60, maxContextPct: 90 },
28
- codex: { warnContextPct: 15, maxContextPct: 22 },
28
+ codex: { warnContextPct: 60, maxContextPct: 85 },
29
29
  };
30
+ // rollout 실행 창을 못 읽으면 1.05M 표 값이 분모라 실제 창보다 비율이 작게 나온다.
31
+ const CODEX_MODEL_WINDOW_THRESHOLDS = { warnContextPct: 15, maxContextPct: 22 };
30
32
 
31
33
  const rolloutCache = new Map();
32
34
 
33
- export function modelContext(cli, model, estimatedContextTokens = null) {
35
+ export function modelContext(
36
+ cli,
37
+ model,
38
+ estimatedContextTokens = null,
39
+ rolloutContextWindow = null,
40
+ ) {
34
41
  const entry = MODEL_CONTEXT.find(
35
42
  (item) => item.cli === cli && item.model.test(model || ""),
36
43
  );
@@ -38,18 +45,20 @@ export function modelContext(cli, model, estimatedContextTokens = null) {
38
45
  const executionContextLimitTokens =
39
46
  cli === "claude" && process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT === "1"
40
47
  ? 200_000
41
- : null;
42
- const effectiveLimit = Math.min(
43
- contextLimitTokens ?? Infinity,
44
- executionContextLimitTokens ?? Infinity,
45
- );
48
+ : rolloutContextWindow;
49
+ // Codex 는 rollout 이 보고한 창이 실제 실행 한도라 표 값과 무관하게 그 창을 쓴다.
50
+ const effectiveLimit =
51
+ cli === "codex"
52
+ ? (executionContextLimitTokens ?? contextLimitTokens)
53
+ : contextLimitTokens &&
54
+ Math.min(contextLimitTokens, executionContextLimitTokens ?? Infinity);
46
55
  return {
47
56
  estimatedContextTokens,
48
57
  contextLimitTokens,
49
58
  contextLimitSource: entry?.source ?? null,
50
59
  ...(executionContextLimitTokens ? { executionContextLimitTokens } : {}),
51
60
  contextPct:
52
- contextLimitTokens && Number.isFinite(estimatedContextTokens)
61
+ effectiveLimit && Number.isFinite(estimatedContextTokens)
53
62
  ? (estimatedContextTokens / effectiveLimit) * 100
54
63
  : null,
55
64
  };
@@ -60,7 +69,10 @@ export function contextGuard(
60
69
  context,
61
70
  { warnContextPct, maxContextPct } = {},
62
71
  ) {
63
- const defaults = CONTEXT_THRESHOLDS[cli];
72
+ const defaults =
73
+ cli === "codex" && !context?.executionContextLimitTokens
74
+ ? CODEX_MODEL_WINDOW_THRESHOLDS
75
+ : CONTEXT_THRESHOLDS[cli];
64
76
  const warn = warnContextPct ?? defaults?.warnContextPct ?? 0;
65
77
  const max = maxContextPct ?? defaults?.maxContextPct ?? 0;
66
78
  const fields = {
@@ -90,6 +102,7 @@ export function contextGuard(
90
102
  inputSent: false,
91
103
  hint: "세션을 승계한 뒤 새 세션에 요청하라.",
92
104
  ...fields,
105
+ thresholdPct: max,
93
106
  };
94
107
  }
95
108
  return warn > 0 && fields.contextPct >= warn
@@ -206,7 +219,12 @@ export async function readCodexContext(rolloutPath, threadId) {
206
219
  state.mtimeMs = stat.mtimeMs;
207
220
  rolloutCache.set(rolloutPath, state);
208
221
  return {
209
- ...modelContext("codex", state.model, state.tokens),
222
+ ...modelContext(
223
+ "codex",
224
+ state.model,
225
+ state.tokens,
226
+ state.executionContextLimitTokens,
227
+ ),
210
228
  model: state.model,
211
229
  compactCount: state.compactCount,
212
230
  compact: state.compact,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.63.0",
3
+ "version": "10.65.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Antigravity, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/pack.mjs CHANGED
@@ -120,42 +120,54 @@ function buildFileSet(baseDir) {
120
120
  }
121
121
 
122
122
  /**
123
- * remote 패키지 내 .mjs 파일에서 core 모듈을 참조하는 상대 import를
124
- * @triflux/core 패키지 경로로 리라이팅한다.
123
+ * remote 패키지 내 .mjs 파일 하나에서 core 모듈을 참조하는 상대 import를
124
+ * @triflux/core 패키지 경로로 리라이팅한다. release:check-mirror 도 이 함수로 기대본을 만든다.
125
125
  */
126
+ export function rewriteRemoteSource(
127
+ content,
128
+ filePath,
129
+ remotePkgDir,
130
+ corePkgDir,
131
+ coreFiles = buildFileSet(corePkgDir),
132
+ ) {
133
+ let rewrites = 0;
134
+ const rewriteRelativeSpecifier = (match, pre, importPath, post) => {
135
+ const resolved = normalize(join(dirname(filePath), importPath));
136
+ // import 대상이 remote 패키지 내에 있으면 그대로 유지
137
+ if (existsSync(resolved)) return match;
138
+ // core 패키지에 동일 상대경로로 존재하는지 확인
139
+ const relToRemote = relative(remotePkgDir, resolved).replace(/\\/g, "/");
140
+ const coreEquiv = normalize(join(corePkgDir, relToRemote));
141
+ if (coreFiles.has(coreEquiv)) {
142
+ rewrites++;
143
+ return `${pre}@triflux/core/${relToRemote}${post}`;
144
+ }
145
+ return match;
146
+ };
147
+
148
+ const rewritten = content
149
+ .replace(/(from\s+['"])(\.[^'"]+)(['"])/g, rewriteRelativeSpecifier)
150
+ .replace(
151
+ /(import\(\s*['"])(\.[^'"]+)(['"]\s*\))/g,
152
+ rewriteRelativeSpecifier,
153
+ );
154
+ return { content: rewritten, rewrites };
155
+ }
156
+
126
157
  function rewriteRemoteImports(remotePkgDir, corePkgDir) {
127
158
  const coreFiles = buildFileSet(corePkgDir);
128
159
  let totalRewrites = 0;
129
160
 
130
161
  for (const filePath of walkMjs(remotePkgDir)) {
131
- let content = readFileSync(filePath, "utf8");
132
- let changed = false;
133
-
134
- const rewriteRelativeSpecifier = (match, pre, importPath, post) => {
135
- const resolved = normalize(join(dirname(filePath), importPath));
136
- // import 대상이 remote 패키지 내에 있으면 그대로 유지
137
- if (existsSync(resolved)) return match;
138
- // core 패키지에 동일 상대경로로 존재하는지 확인
139
- const relToRemote = relative(remotePkgDir, resolved).replace(/\\/g, "/");
140
- const coreEquiv = normalize(join(corePkgDir, relToRemote));
141
- if (coreFiles.has(coreEquiv)) {
142
- changed = true;
143
- totalRewrites++;
144
- return `${pre}@triflux/core/${relToRemote}${post}`;
145
- }
146
- return match;
147
- };
148
-
149
- content = content.replace(
150
- /(from\s+['"])(\.[^'"]+)(['"])/g,
151
- rewriteRelativeSpecifier,
162
+ const { content, rewrites } = rewriteRemoteSource(
163
+ readFileSync(filePath, "utf8"),
164
+ filePath,
165
+ remotePkgDir,
166
+ corePkgDir,
167
+ coreFiles,
152
168
  );
153
- content = content.replace(
154
- /(import\(\s*['"])(\.[^'"]+)(['"]\s*\))/g,
155
- rewriteRelativeSpecifier,
156
- );
157
-
158
- if (changed) writeFileSync(filePath, content);
169
+ if (rewrites > 0) writeFileSync(filePath, content);
170
+ totalRewrites += rewrites;
159
171
  }
160
172
 
161
173
  return totalRewrites;
@@ -19,7 +19,7 @@ import {
19
19
  } from "node:fs";
20
20
  import { dirname, join } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
- import { CORE_ENTRIES } from "../pack.mjs";
22
+ import { CORE_ENTRIES, rewriteRemoteSource } from "../pack.mjs";
23
23
 
24
24
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
25
25
  const DEFAULT_REPO_ROOT = join(SCRIPT_DIR, "..", "..");
@@ -77,6 +77,7 @@ function walkRelFiles(root, skipRels = new Set()) {
77
77
  // the two drift classes that shipped silently in v10.33.0: a broken remote
78
78
  // import (relative specifier to a core-only module) and a stale non-JS asset
79
79
  // (the tray UI), neither of which the triflux/core byte-mirror check can see.
80
+ // .mjs 본문도 pack:remote 리라이트를 거친 root 와 비교한다. 해석만 보면 옛 구현이 남아도 OK 였다.
80
81
  const REMOTE_NON_MIRROR = new Set([
81
82
  "package.json",
82
83
  "package-lock.json",
@@ -99,16 +100,12 @@ function extractImportSpecifiers(content) {
99
100
  return specs;
100
101
  }
101
102
 
102
- function restoreRemoteCoreHubImports(content) {
103
- return content
104
- .replace(
105
- /(from\s+["'])@triflux\/core\/hub\/([^"']+)(["'])/g,
106
- "$1../../hub/$2$3",
107
- )
108
- .replace(
109
- /(import\(\s*["'])@triflux\/core\/hub\/([^"']+)(["']\s*\))/g,
110
- "$1../../hub/$2$3",
111
- );
103
+ function firstDiffLine(expected, actual) {
104
+ const a = expected.split("\n");
105
+ const b = actual.split("\n");
106
+ let i = 0;
107
+ while (a[i] === b[i]) i++;
108
+ return `line ${i + 1}: root ${JSON.stringify(a[i] ?? "<EOF>")} / remote ${JSON.stringify(b[i] ?? "<EOF>")}`;
112
109
  }
113
110
 
114
111
  function checkRemoteMirror(repoRoot) {
@@ -140,15 +137,21 @@ function checkRemoteMirror(repoRoot) {
140
137
  });
141
138
  }
142
139
  }
143
- if (rel.startsWith("scripts/lib/")) {
144
- const rootPath = join(repoRoot, rel);
145
- if (
146
- existsSync(rootPath) &&
147
- !readFileSync(rootPath).equals(
148
- Buffer.from(restoreRemoteCoreHubImports(content)),
149
- )
150
- ) {
151
- issues.push({ path: displayPath, kind: "remote-content-diff" });
140
+ const rootPath = join(repoRoot, rel);
141
+ if (existsSync(rootPath)) {
142
+ // pack:remote 와 같은 리라이트를 root 원본에 적용한 기대본과 비교한다.
143
+ const { content: expected } = rewriteRemoteSource(
144
+ readFileSync(rootPath, "utf8"),
145
+ remotePath,
146
+ remoteRoot,
147
+ coreRoot,
148
+ );
149
+ if (content !== expected) {
150
+ issues.push({
151
+ path: displayPath,
152
+ kind: "remote-content-diff",
153
+ detail: firstDiffLine(expected, content),
154
+ });
152
155
  }
153
156
  }
154
157
  } else {
@@ -329,13 +332,14 @@ function main() {
329
332
  }
330
333
  } else {
331
334
  console.log(
332
- "Mirror OK — packages/triflux + packages/core (byte) and packages/remote (imports + assets) match root",
335
+ "Mirror OK — packages/triflux + packages/core (byte) and packages/remote (import-normalized content) match root",
333
336
  );
334
337
  }
335
338
  } else {
336
339
  console.log(`Mirror mismatch (${result.issues.length} issues):`);
337
340
  for (const i of result.issues) {
338
341
  console.log(` ${i.kind.padEnd(18)} ${i.path}`);
342
+ if (i.detail) console.log(` ${i.detail}`);
339
343
  }
340
344
  if (result.fixed.length > 0) {
341
345
  console.log(`Fixed during run (${result.fixed.length}):`);
@@ -29,6 +29,10 @@ macOS의 iTerm2에서 로컬 tmux 세션을 `start`하면 리드가 있는 창
29
29
 
30
30
  새 세션 이름은 `<월>.<일> <주제>`로 짓는다. `start --name`을 생략하면 자동으로 만들고 `nameGenerated: true`를 보고한다. `--resume` 또는 `--resume-last`는 `--name`을 명시한 경우에만 이름을 적용한다. 승계 세션에는 기존 이름 뒤에 ` 2`, 이후 ` 3`을 붙인다. 이름 적용을 요청한 경우 결과의 `nameApplied: false`는 미적용을 뜻한다. agy의 `namePending: true`는 첫 일반 ask에서 적용할 이름이 보류됐다는 뜻이다. Codex `start`는 `/rename` 제출을 입력창이 빌 때까지 확인하고, 이름으로 찾은 thread UUID를 `threadId`로 돌려주며 tmux 세션 옵션 `@tfx_codex_thread`에 남긴다. 로컬 Codex `--resume-last`는 Codex가 고른 thread를 사후에 찾는다. 띄운 뒤 공유 app-server daemon에 로드된 같은 cwd의 thread 중 rollout에 그 시각 이후의 resume 기록(`thread_settings_applied`)이 생긴 것이 정확히 하나이고 1.5초 뒤에도 같을 때만 `threadId`로 쓴다. 이 확인은 5초 안에 끝난다. TUI가 daemon을 쓰지 않거나 같은 cwd에서 동시에 resume이 일어나면 `null`이다. 기존 Codex UDS thread는 `tfx-live rename --cli codex --transport uds --thread ID --name "<이름>"`으로 이름을 바꾼다.
31
31
 
32
+ Codex 폴더 신뢰 화면이나 Claude 프로젝트 MCP 승인 화면이 뜨면 `start`는 화면에 답하지 않고 `ready: false`, `blockedBy`(`codex-trust-folder` 또는 `claude-mcp-approval`), 할 일을 적은 `warning`을 반환한다. 세션은 남아 있으므로 tmux 세션에 붙어 직접 고른 뒤 지시를 보낸다.
33
+
34
+ Claude, agy, 예전 Codex 문구(`Do you trust the contents of this directory`)의 폴더 신뢰 화면은 기본으로 자동 승인한다. `start --no-auto-trust`를 주면 이 화면에도 키를 보내지 않고 같은 형식으로 `blockedBy`(`claude-trust-folder`, `agy-trust-folder`, `codex-trust-folder`)를 반환한다. `--resume`, `--resume-last`, `--remote`에도 똑같이 적용된다.
35
+
32
36
  ## agy 세션
33
37
 
34
38
  ```bash
@@ -101,19 +105,20 @@ Claude와 Codex는 네이티브 goal을 우선 쓰고, goal-driven은 agy처럼
101
105
 
102
106
  ### 컨텍스트 판단
103
107
 
104
- 분모는 공식 모델 창이고 실행 한도가 더 작으면 그 한도 아래에서 운영한다. 아래 비율은 공식 권고가 아닌 **[추론] 운영값**이다. 모델 문서가 바뀌면 한도도 갱신한다.
108
+ Claude의 분모는 공식 모델 창과 실행 한도 중 작은 값이다. Codex의 분모는 rollout이 보고한 실행 창이고, 없을 때만 공식 창이다. 아래 비율은 공식 권고가 아닌 **[추론] 운영값**이다. 모델 문서가 바뀌면 한도도 갱신한다.
105
109
 
106
110
  | CLI | 준비와 경고 | compact | 신규 배정 중지·handoff | 전송 거부 |
107
111
  | --- | ---: | ---: | ---: | ---: |
108
112
  | Claude Code 1M 모델 | 60% | 75% | 85% | 90% |
109
- | Codex | 15% | 18% | 20% | 22% |
113
+ | Codex rollout 실행 창 | 60% | - | - | 85% |
114
+ | Codex 실행 창 없음(1.05M 분모) | 15% | 18% | 20% | 22% |
110
115
  | agy | 12% | 없음 | 15% | 18% |
111
116
 
112
- Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
117
+ Claude는 85% 도달 시 바로 handoff를 지시한다. Codex 실행 창 행의 compact와 handoff 비율은 아직 정하지 않았다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
113
118
 
114
- 결과에는 `estimatedContextTokens`, `contextLimitTokens`, `contextLimitSource`, `contextPct`가 포함된다. Claude 실행 한도는 `executionContextLimitTokens`로 별도 보고하며 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`이면 200,000이다. 판정에는 공식 창과 실행 한도 중 작은 값을 쓴다. 한도를 모르면 비율은 `null`로 두고 경고만 한다. 사용량이 없는 `probe`는 `context: null`이다. 컨텍스트를 확인하려면 직접 `tfx-live probe`를 실행한다. `interrupt`, 자동 `ask`의 daemon probe, `stop --short`는 transcript를 읽지 않는다.
119
+ 결과에는 `estimatedContextTokens`, `contextLimitTokens`, `contextLimitSource`, `contextPct`가 포함된다. Claude 실행 한도는 `executionContextLimitTokens`로 별도 보고하며 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`이면 200,000이다. Codex는 rollout `token_count`의 `model_context_window`를 `executionContextLimitTokens`로 보고한다. 이 값이 있으면 모델 표에 없는 모델이라도 그 창으로 판정하고, 없으면 1,050,000 표 값을 분모로 쓴다. 한도를 모르면 비율은 `null`로 두고 경고만 한다. 사용량이 없는 `probe`는 `context: null`이다. 컨텍스트를 확인하려면 직접 `tfx-live probe`를 실행한다. `interrupt`, 자동 `ask`의 daemon probe, `stop --short`는 transcript를 읽지 않는다.
115
120
 
116
- `ask`의 `--warn-context-pct`와 `--max-context-pct`는 경고·거부 비율을 덮어쓰며 0이면 해당 가드를 끈다. 기본값은 표의 경고·전송 거부 열이다. 거부 결과는 `status: "failed"`, `done: false`, `context-limit`와 컨텍스트 필드를 돌려준다. 자세한 근거와 실측은 [컨텍스트 정책 연구](../../docs/research/tfx-live-context-policy-2026-10.md)에 둔다.
121
+ `ask`의 `--warn-context-pct`와 `--max-context-pct`는 경고·거부 비율을 덮어쓰며 0이면 해당 가드를 끈다. 기본값은 표의 경고·전송 거부 열이다. Codex는 `executionContextLimitTokens`가 있으면 실행 창 행을, 없으면 1.05M 분모 행을 쓴다. 거부 결과는 `status: "failed"`, `done: false`, `context-limit`, 컨텍스트 필드와 적용한 거부 비율 `thresholdPct`를 돌려준다. 경고의 `thresholdPct`는 적용한 경고 비율이다. 자세한 근거와 실측은 [컨텍스트 정책 연구](../../docs/research/tfx-live-context-policy-2026-10.md)에 둔다.
117
122
 
118
123
  ### 수동 compact와 종료
119
124