triflux 10.54.0 → 10.55.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.
package/README.ko.md CHANGED
@@ -141,7 +141,7 @@ Codex는 이름 붙은 프로필로 실행한다. 모델 ID는 `~/.codex/<프로
141
141
 
142
142
  **라이브 세션.** `tfx-live`는 Claude Code와 Codex TUI 세션을 조종한다. Claude 데몬 대상
143
143
  (`--short`/`--session-id`)은 UDS를 먼저 시도하고, `--session`도 주면 실패 시 tmux로 넘어간다.
144
- Codex `ask`는 `codex queue`로 메시지를 쌓고(TUI에 `[from <보낸 세션>]` 첫 줄로 보인다) 못 쓰면 이유를
144
+ Codex `ask`는 Codex 큐(app-server `thread/queue/add`)에 메시지를 쌓고(TUI에 `[from <보낸 세션>]` 첫 줄로 보인다) 못 쓰면 이유를
145
145
  남기고 tmux로 보낸다. `--transport uds --thread <id|auto>`로 UDS도 쓸 수 있다. `peer`는 두 세션 사이를
146
146
  `--rounds`만큼 중계한다. triflux의 Codex
147
147
  훅이 실행 중인 Codex 세션을 `~/.local/state/triflux/codex-sessions/`에 기록하므로
package/README.md CHANGED
@@ -143,7 +143,7 @@ escalation chain: [`.claude/rules/tfx-escalation-chain.md`](.claude/rules/tfx-es
143
143
 
144
144
  **Live sessions.** `tfx-live` drives Claude Code and Codex TUI sessions. Claude daemon targets
145
145
  (`--short`/`--session-id`) try UDS first and fall back to tmux when `--session` is also given;
146
- Codex `ask` queues the message with `codex queue` (shown in the TUI with a
146
+ Codex `ask` queues the message through the app-server `thread/queue/add` API (shown in the TUI with a
147
147
  `[from <sender>]` first line) and falls back to tmux with a reported reason; UDS stays available with
148
148
  `--transport uds --thread <id|auto>`. `peer` relays between two sessions for `--rounds`. triflux's Codex hook records running Codex
149
149
  sessions under `~/.local/state/triflux/codex-sessions/`, so `tfx-live list-sessions --cli codex|claude`
@@ -18,7 +18,7 @@ Codex에서는 `$tfx-live`로 호출한다. CLI의 `--cli`는 현재 호스트
18
18
  ## 전송 경로
19
19
 
20
20
  - Claude `--short` 또는 `--session-id`는 기본 `auto`로 UDS를 먼저 시도한다. `--session`이 있고 입력 미전송이 확인된 UDS 실패라면 tmux로 전환하고 `~/.claude/cache/triflux/tfx-live/bug-reports/uds-fallback-*.json`을 남긴다. 컨텍스트 가드 거부 시에는 전환하지 않는다.
21
- - Codex `ask`는 `codex queue`가 기본이다. 아래 "Codex 메시지 전송" 순서를 따른다. 기존 Codex thread의 UDS 연결은 `--transport uds --thread <id|auto>`를 명시한다.
21
+ - Codex `ask`는 Codex 큐(app-server `thread/queue/add`)가 기본이다. 아래 "Codex 메시지 전송" 순서를 따른다. 기존 Codex thread의 UDS 연결은 `--transport uds --thread <id|auto>`를 명시한다.
22
22
  - daemon 참조가 없는 Claude는 tmux가 기본이다.
23
23
  - UDS 결과는 `matchedCompletion === true`일 때만 `done: true`다. timeout이나 연결 종료는 완료가 아니다.
24
24
  - bridge 선택 순서는 `--bridge`, `$TFX_BRIDGE`, `$TFX_REPO_ROOT/hub/bridge.mjs`, 번들된 `hub/bridge.mjs`다.
@@ -48,7 +48,7 @@ tfx-live stop --cli codex --session cx1
48
48
 
49
49
  ### Codex 메시지 전송
50
50
 
51
- 1. 기본은 `codex queue --thread <UUID>`다. 입력창을 건드리지 않고, 바쁘면 쌓였다가 앞 턴이 끝난 직후 순서대로 들어간다. 유휴 TUI는 약 20초 주기로 큐를 가져간다.
51
+ 1. 기본은 app-server 실험 API `thread/queue/add`로 쌓는 것이다. 프롬프트가 프로세스 인자에 보이지 않는다. 입력창을 건드리지 않고, 바쁘면 쌓였다가 앞 턴이 끝난 직후 순서대로 들어간다. 유휴 TUI는 약 20초 주기로 큐를 가져간다. add가 없는 Codex에서만 `codex queue --thread <UUID> --message`로 보내며, 이때 프롬프트가 argv에 보이고 결과에 `queueVia: "codex-queue-argv"`와 `queueFallbackReason`이 남는다. 평소 값은 `queueVia: "app-server"`다.
52
52
  2. 첫 줄 머리말은 `[from <보낸 세션 이름>] [tfx-live req=<id>]`다. 이름은 `--from`, `$TFX_LIVE_FROM`, Claude 세션 기록, Codex `session_index` 순으로 찾고 없으면 머리말 없이 표식만 붙인다. Codex TUI는 Claude처럼 접어 보여 주지 않고 사용자 입력으로 그대로 표시한다.
53
53
  3. thread는 `--thread UUID`, `start`가 남긴 `@tfx_codex_thread`, Codex 세션 레지스트리, pane 프로세스의 `resume <UUID>` 인자, pane cwd와 같은 rollout 하나 순으로 찾는다. cwd 대응은 그 cwd의 Codex TUI가 하나일 때만 쓰고 exec 워커와 서브에이전트 thread는 뺀다. 이름으로는 보내지 않는다(`codex queue`의 이름 조회가 실패한다).
54
54
  4. tmux 직접 입력은 queue를 못 쓸 때만 쓴다: 원격 호스트, thread를 못 찾음(`start` 밖에서 `resume --last`로 띄운 세션, `start --resume-last`의 `threadId: null`, 같은 cwd의 TUI 여럿 포함), `codex queue` 오류(0.160 미만 포함). 결과에 `transport: "tmux"`, `transportRequested: "queue"`, `fallbackReason`이 남는다. 시간 초과처럼 queue가 이미 쌓았을 수도 있는 오류는 `status: "unknown"`으로 끝내고 재전송하지 않는다.
package/bin/tfx-live.mjs CHANGED
@@ -95,7 +95,7 @@ function usage(command) {
95
95
  " tfx-live start --session NAME [--name NAME] [--cli codex|claude] [--model ID] [--effort TIER] [--cwd DIR] [--remote HOST] [--resume ID] [--resume-last 1] [--ready-timeout 30] [--poll-interval 1500]",
96
96
  " tfx-live ask --session NAME[:WINDOW.PANE] --prompt TEXT [--cli codex|claude] [--if-busy wait|fail|interrupt] [--busy-timeout 60] [--timeout 60] [--remote HOST] [--settle 1500] [--poll-interval 1500]",
97
97
  " tfx-live ask --cli codex [--transport queue|tmux] (--session NAME[:WINDOW.PANE] | --thread UUID) --prompt TEXT [--from NAME] [--timeout 60] [--no-wait]",
98
- " Codex ask defaults to `codex queue` (queued, delivered when the TUI picks it up); tmux paste is the reported fallback and the path for slash commands.",
98
+ " 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.",
99
99
  " 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]",
100
100
  " tfx-live ask --transport uds|auto (--short SHORT | --session-id ID) --prompt TEXT [--config-dir DIR] [--bridge ABS] [--session NAME (auto fallback)] [--timeout 60]",
101
101
  " ask options: --no-wait --no-relay-tag --warn-context-pct N --max-context-pct N (0 disables; Claude 60/90, Codex 15/22).",
@@ -2725,6 +2725,10 @@ async function doAskViaQueue(adapter, opts, deps = {}) {
2725
2725
  ok: true,
2726
2726
  inputSent: true,
2727
2727
  queuedMessageId: queued.queuedMessageId,
2728
+ queueVia: queued.via,
2729
+ ...(queued.fallbackReason
2730
+ ? { queueFallbackReason: queued.fallbackReason }
2731
+ : {}),
2728
2732
  submittedAt: new Date().toISOString(),
2729
2733
  };
2730
2734
  // 표식이 없으면 rollout 에서 이 요청의 턴을 가려낼 수 없다.
package/bin/triflux.mjs CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  cleanupLegacyMcp,
45
45
  cleanupTfxHub,
46
46
  findProjectHubEntries,
47
+ pinRegistryMcpPackages,
47
48
  } from "../scripts/lib/legacy-mcp-cleanup.mjs";
48
49
  import {
49
50
  addRegistryServer,
@@ -66,11 +67,12 @@ import {
66
67
  applyStatusLine,
67
68
  cleanupStaleSkills,
68
69
  ensureCodexProfiles,
69
- ensureTrifluxMods,
70
70
  getVersion,
71
+ inspectTrifluxMods,
71
72
  isSkillSupportedOnPlatform,
72
73
  LEGACY_CODEX_MODELS,
73
74
  listInlineProfileNames,
75
+ MODS_UPDATE_COMMAND,
74
76
  persistSettings,
75
77
  REQUIRED_CODEX_PROFILES,
76
78
  retireOldInstallFiles,
@@ -1260,6 +1262,9 @@ async function cmdSetup(options = {}) {
1260
1262
  });
1261
1263
  for (const warning of hubCleanup.warnings) warn(warning);
1262
1264
  if (hubCleanup.changed) ok("제거된 허브의 설정과 실행 흔적 정리");
1265
+ const mcpPins = pinRegistryMcpPackages({ backups: mcpBackups });
1266
+ for (const warning of mcpPins.warnings) warn(warning);
1267
+ if (mcpPins.pinned) ok(`MCP 패키지 고정 버전 반영: ${mcpPins.pinned}개 항목`);
1263
1268
  if (fromUpdate) refreshSetupCaches();
1264
1269
 
1265
1270
  console.log(`\n${BOLD}triflux setup${RESET}\n`);
@@ -1269,7 +1274,6 @@ async function cmdSetup(options = {}) {
1269
1274
  }
1270
1275
  retireOldInstallFiles(info);
1271
1276
  reportSkillSync();
1272
- ensureTrifluxMods({ install: mods, log: console.log, warn });
1273
1277
 
1274
1278
  // ── 결과 추적 ──
1275
1279
  const summary = [];
@@ -1367,7 +1371,7 @@ async function cmdSetup(options = {}) {
1367
1371
  }
1368
1372
 
1369
1373
  // Codex 프로필 정리가 끝난 뒤에 훅을 다룬다.
1370
- await runConsentSteps({ log: info, warn });
1374
+ await runConsentSteps({ modsInstall: mods, log: info, warn });
1371
1375
 
1372
1376
  // ── psmux 기본 셸 자동 수정 (cmd.exe → PowerShell) ──
1373
1377
  if (process.platform === "win32" && which("psmux")) {
@@ -1860,6 +1864,9 @@ async function cmdDoctor(options = {}) {
1860
1864
  status: invalidCount > 0 ? "issues" : "ok",
1861
1865
  actions: mcpSync.actions,
1862
1866
  });
1867
+ for (const action of mcpSync.actions)
1868
+ if (action.status === "warning" && action.message)
1869
+ warn(`${action.label}: ${action.message}`);
1863
1870
  if (updatedCount > 0)
1864
1871
  ok(`MCP registry 동기화: ${updatedCount}개 설정 반영됨`);
1865
1872
  else info("MCP registry: 이미 최신 상태");
@@ -1964,6 +1971,21 @@ async function cmdDoctor(options = {}) {
1964
1971
  warn(`미설치 ${GRAY}(선택사항)${RESET}`);
1965
1972
  }
1966
1973
 
1974
+ // mods 가 없으면 effort 없이 부른 서브에이전트가 리드 effort 를 물려받는다.
1975
+ section("Claude mods");
1976
+ const mods = inspectTrifluxMods({ pkgVersion: PKG.version });
1977
+ addDoctorCheck(report, { name: "triflux-mods", ...mods });
1978
+ if (mods.status === "current")
1979
+ ok(`설치됨 ${DIM}v${mods.installedVersion}${RESET}`);
1980
+ else if (mods.status === "missing") {
1981
+ warn("미설치");
1982
+ info("수정: tfx setup --mods");
1983
+ } else if (mods.status === "outdated") {
1984
+ warn(`버전 차이: 설치 ${mods.installedVersion}, 패키지 ${PKG.version}`);
1985
+ info(`수정: ${MODS_UPDATE_COMMAND}`);
1986
+ } else if (mods.status === "failed") warn(`확인 실패: ${mods.error}`);
1987
+ else info(`건너뜀 (${mods.reason})`);
1988
+
1967
1989
  // Codex CLI
1968
1990
  section(`Codex CLI ${WHITE_BRIGHT}●${RESET}`);
1969
1991
  const codexCli = checkCliCrossShell(
@@ -3607,7 +3629,10 @@ function cmdMcp(args = [], options = {}) {
3607
3629
  }
3608
3630
  const label = `${action.label} ${DIM}(${formatPathForDisplay(action.filePath)})${RESET}`;
3609
3631
  if (action.status === "updated") ok(`${label} → updated`);
3610
- else if (action.status === "warning") warn(`${label} → warning`);
3632
+ else if (action.status === "warning")
3633
+ warn(
3634
+ `${label} → warning${action.message ? `: ${action.message}` : ""}`,
3635
+ );
3611
3636
  else if (action.status === "invalid-config")
3612
3637
  fail(`${label} → invalid-config`);
3613
3638
  else info(`${stripAnsi(label)} → ${action.status}`);
@@ -1,4 +1,5 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { realpathSync } from "node:fs";
3
4
  import { open, readdir, readFile } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
@@ -175,16 +176,57 @@ export async function countCodexTuiInCwd(cwd, { execFn = execFileAsync } = {}) {
175
176
  }
176
177
  }
177
178
 
179
+ // app-server 의 thread/queue/add 로 쌓아 프롬프트가 ps 에 보이지 않게 한다.
178
180
  export async function queueCodexMessage({
179
181
  threadId,
180
182
  message,
181
183
  env = process.env,
182
184
  execFn = execFileAsync,
185
+ spawnFn = spawn,
183
186
  }) {
187
+ let addSent = false;
188
+ try {
189
+ const result = await withCodexAppServer(
190
+ (request) => {
191
+ addSent = true;
192
+ return request("thread/queue/add", {
193
+ threadId,
194
+ input: [{ type: "text", text: message, text_elements: [] }],
195
+ clientUserMessageId: randomUUID(),
196
+ });
197
+ },
198
+ { env, spawnFn },
199
+ );
200
+ const id = result?.queuedSubmission?.id;
201
+ if (id) return { queuedMessageId: id, via: "app-server" };
202
+ throw new Error(
203
+ `unexpected thread/queue/add result: ${JSON.stringify(result)}`,
204
+ );
205
+ } catch (error) {
206
+ // 오류 응답이 아니면 add 가 이미 쌓였을 수 있어 재전송하지 않는다.
207
+ if (addSent && !error.rpcError) {
208
+ error.maybeQueued = true;
209
+ throw error;
210
+ }
211
+ const queued = await queueCodexMessageByArgv({
212
+ threadId,
213
+ message,
214
+ env,
215
+ execFn,
216
+ });
217
+ return {
218
+ ...queued,
219
+ via: "codex-queue-argv",
220
+ fallbackReason: error.message,
221
+ };
222
+ }
223
+ }
224
+
225
+ // add 를 못 쓰는 Codex(app-server 없음, 메서드 없음)만 이 경로로 와서 프롬프트가 argv 에 보인다.
226
+ async function queueCodexMessageByArgv({ threadId, message, env, execFn }) {
184
227
  let stdout;
185
228
  try {
186
229
  // cwd 의 프로젝트 .codex/config.toml 이 깨져 있으면 queue 가 실패하므로 HOME 에서 실행한다.
187
- // codex queue 는 메시지를 --message 인자로만 받아(stdin, 파일 옵션 없음) ps 노출을 피할 수 없다.
188
230
  ({ stdout } = await execFn(
189
231
  "codex",
190
232
  ["queue", `--thread=${threadId}`, `--message=${message}`],
@@ -216,7 +258,7 @@ export async function queueCodexMessage({
216
258
  return { queuedMessageId: match[1] };
217
259
  }
218
260
 
219
- // 공식 app-server 를 stdio 로 잠깐 띄워 요청을 보낸다. 큐 조회와 삭제가 실험 API 라 experimentalApi 를 켠다.
261
+ // 공식 app-server 를 stdio 로 잠깐 띄워 요청을 보낸다. 큐 API 가 실험 API 라 experimentalApi 를 켠다.
220
262
  export async function withCodexAppServer(
221
263
  run,
222
264
  { env = process.env, timeoutMs = 30_000, spawnFn = spawn } = {},
@@ -251,7 +293,11 @@ export async function withCodexAppServer(
251
293
  if (!waiter) return;
252
294
  pending.delete(message.id);
253
295
  if (message.error)
254
- waiter.reject(new Error(`${waiter.method}: ${message.error.message}`));
296
+ waiter.reject(
297
+ Object.assign(new Error(`${waiter.method}: ${message.error.message}`), {
298
+ rpcError: message.error,
299
+ }),
300
+ );
255
301
  else waiter.resolve(message.result);
256
302
  });
257
303
  const send = (message) =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.54.0",
3
+ "version": "10.55.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": {
@@ -15,9 +15,10 @@ import {
15
15
  writeFileSync,
16
16
  } from "node:fs";
17
17
  import { createRequire } from "node:module";
18
- import { homedir, platform as osPlatform } from "node:os";
18
+ import { homedir, platform as osPlatform, userInfo } from "node:os";
19
19
  import { basename, dirname, join, resolve } from "node:path";
20
20
  import { isDeepStrictEqual } from "node:util";
21
+ import { loadRegistryOrDefault, pinnedArgs } from "./mcp-guard-engine.mjs";
21
22
 
22
23
  const require = createRequire(import.meta.url);
23
24
  const toml = require("@iarna/toml");
@@ -1236,3 +1237,131 @@ export function cleanupTfxHub({
1236
1237
  }
1237
1238
  return result;
1238
1239
  }
1240
+
1241
+ // doctor 가 레지스트리와 비교하는 사용자 설정 파일. [HOME 기준 경로, 형식, 클라이언트]
1242
+ // 프로젝트의 .mcp.json 은 저장소에 커밋될 수 있어 setup 이 바꾸지 않는다(doctor --fix, mcp sync 몫).
1243
+ const REGISTRY_PIN_FILES = [
1244
+ [".claude.json", "json", "claude"],
1245
+ [".mcp.json", "json", "claude"],
1246
+ [".claude/mcp.json", "json", "claude"],
1247
+ [".codex/config.toml", "toml", "codex"],
1248
+ [".gemini/config/mcp_config.json", "json", "antigravity"],
1249
+ ];
1250
+
1251
+ function pinJson(original, servers) {
1252
+ const data = JSON.parse(original);
1253
+ let count = 0;
1254
+ for (const [name, server] of servers) {
1255
+ const entry = data?.mcpServers?.[name];
1256
+ const args = pinnedArgs(entry, server);
1257
+ if (!args) continue;
1258
+ entry.args = args;
1259
+ count++;
1260
+ }
1261
+ return {
1262
+ output: count ? `${JSON.stringify(data, null, 2)}\n` : original,
1263
+ count,
1264
+ };
1265
+ }
1266
+
1267
+ // [mcp_servers.<name>] 머리글 다음 줄부터 다음 표 머리글 전까지의 범위.
1268
+ function tomlSectionBody(text, name) {
1269
+ let offset = 0;
1270
+ let start = -1;
1271
+ for (const line of text.split(/(?<=\n)/)) {
1272
+ if (start >= 0 && /^\s*\[/.test(line)) return { start, end: offset };
1273
+ const match = line.match(
1274
+ /^\s*\[mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\]\s*(?:#.*)?\r?\n?$/,
1275
+ );
1276
+ offset += line.length;
1277
+ if ((match?.[1] ?? match?.[2] ?? match?.[3]) === name) start = offset;
1278
+ }
1279
+ return start >= 0 ? { start, end: offset } : null;
1280
+ }
1281
+
1282
+ function pinToml(original, servers) {
1283
+ const data = toml.parse(original);
1284
+ let output = original;
1285
+ let count = 0;
1286
+ for (const [name, server] of servers) {
1287
+ const args = pinnedArgs(data.mcp_servers?.[name], server);
1288
+ const section = args && tomlSectionBody(output, name);
1289
+ if (!section) continue;
1290
+ const body = output.slice(section.start, section.end);
1291
+ const next = body.replace(
1292
+ /^([ \t]*(?:args|"args"|'args')[ \t]*=[ \t]*)\[[^\]]*\]/m,
1293
+ `$1${JSON.stringify(args)}`,
1294
+ );
1295
+ if (next === body) continue;
1296
+ output = output.slice(0, section.start) + next + output.slice(section.end);
1297
+ data.mcp_servers[name].args = args;
1298
+ count++;
1299
+ }
1300
+ if (count && !isDeepStrictEqual(toml.parse(output), data))
1301
+ throw new Error("TOML 구조 불일치");
1302
+ return { output, count };
1303
+ }
1304
+
1305
+ /** 레지스트리 stdio 항목 중 패키지 버전만 다른 것을 레지스트리 고정 버전으로 바꾼다. */
1306
+ export function pinRegistryMcpPackages({
1307
+ home = homedir(),
1308
+ registry = loadRegistryOrDefault(),
1309
+ backups,
1310
+ } = {}) {
1311
+ const result = {
1312
+ ok: true,
1313
+ changed: false,
1314
+ pinned: 0,
1315
+ backups: [],
1316
+ warnings: [],
1317
+ };
1318
+ // 테스트가 실제 홈의 설정을 바꾸지 않게 한다.
1319
+ if (
1320
+ process.env.NODE_TEST_CONTEXT &&
1321
+ resolve(home) === resolve(userInfo().homedir)
1322
+ ) {
1323
+ result.skipped = true;
1324
+ return result;
1325
+ }
1326
+ const stdio = Object.entries(registry?.servers ?? {}).filter(
1327
+ ([, server]) =>
1328
+ typeof server?.command === "string" && Array.isArray(server.args),
1329
+ );
1330
+ const seen = new Set();
1331
+ for (const [relative, kind, client] of REGISTRY_PIN_FILES) {
1332
+ const servers = stdio.filter(([, server]) =>
1333
+ // mcp-guard-engine 의 serverTargets 처럼 빈 targets 는 기본 세 클라이언트다.
1334
+ (server.targets?.length
1335
+ ? server.targets
1336
+ : ["claude", "codex", "antigravity"]
1337
+ ).includes(client),
1338
+ );
1339
+ if (!servers.length) continue;
1340
+ const file = join(home, relative);
1341
+ try {
1342
+ const target = fileTarget(file);
1343
+ if (!target || !markFirst(seen, target)) continue;
1344
+ const original = readFileSync(target, "utf8");
1345
+ if (!original.trim()) continue;
1346
+ const plan =
1347
+ kind === "toml"
1348
+ ? pinToml(original, servers)
1349
+ : pinJson(original, servers);
1350
+ if (!plan.count) continue;
1351
+ if (
1352
+ fileTarget(file) !== target ||
1353
+ readFileSync(target, "utf8") !== original
1354
+ ) {
1355
+ result.warnings.push(`${file}: 검사 후 변경되어 건너뜀`);
1356
+ continue;
1357
+ }
1358
+ result.backups.push(writeAtomic(file, target, plan.output, backups));
1359
+ result.pinned += plan.count;
1360
+ result.changed = true;
1361
+ } catch {
1362
+ result.warnings.push(`${file}: MCP 버전 고정 반영 실패, 파일 보존`);
1363
+ result.ok = false;
1364
+ }
1365
+ }
1366
+ return result;
1367
+ }
@@ -6,6 +6,7 @@ import {
6
6
  readFileSync,
7
7
  writeFileSync,
8
8
  } from "node:fs";
9
+ import { createRequire } from "node:module";
9
10
  import { homedir } from "node:os";
10
11
  import {
11
12
  basename,
@@ -16,6 +17,7 @@ import {
16
17
  resolve,
17
18
  } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
20
+ import { isDeepStrictEqual } from "node:util";
19
21
 
20
22
  const PROJECT_ROOT = fileURLToPath(new URL("../../", import.meta.url));
21
23
  const DEFAULT_REGISTRY_PATH = join(PROJECT_ROOT, "config", "mcp-registry.json");
@@ -632,20 +634,31 @@ function formatTomlArray(values = []) {
632
634
  return `[${values.map((value) => formatTomlString(value)).join(", ")}]`;
633
635
  }
634
636
 
637
+ // 한 줄에서 열린 [ 와 닫힌 ] 의 차. 문자열과 주석 안의 괄호는 세지 않는다.
638
+ function arrayDepth(text) {
639
+ const code = text
640
+ .replace(/"""[\s\S]*?"""|'''[\s\S]*?'''/g, "")
641
+ .replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, "")
642
+ .replace(/#.*$/, "");
643
+ return (code.match(/\[/g) || []).length - (code.match(/\]/g) || []).length;
644
+ }
645
+
646
+ const TOML_MANAGED_KEYS = new Set([
647
+ "url",
648
+ "transport",
649
+ "command",
650
+ "args",
651
+ "bearer_token_env_var",
652
+ "http_headers",
653
+ "env_http_headers",
654
+ "env",
655
+ "env_vars",
656
+ ]);
657
+
635
658
  function upsertTomlServer(raw, name, config, codex = {}) {
636
659
  const lines = String(raw || "").split(/\r?\n/);
637
660
  const header = `[mcp_servers.${name}]`;
638
- const managedKeys = new Set([
639
- "url",
640
- "transport",
641
- "command",
642
- "args",
643
- "bearer_token_env_var",
644
- "http_headers",
645
- "env_http_headers",
646
- "env",
647
- "env_vars",
648
- ]);
661
+ const managedKeys = TOML_MANAGED_KEYS;
649
662
  const nextManagedLines = [];
650
663
  if (typeof config.url === "string") {
651
664
  nextManagedLines.push(`url = ${formatTomlString(config.url)}`);
@@ -696,6 +709,21 @@ function upsertTomlServer(raw, name, config, codex = {}) {
696
709
  const keyMatch = lines[index].match(/^\s*([A-Za-z0-9_]+)\s*=/);
697
710
  if (!keyMatch || !managedKeys.has(keyMatch[1])) {
698
711
  preserved.push(lines[index]);
712
+ } else {
713
+ // 여러 줄 배열 값은 닫는 ] 까지 함께 걷어낸다. 남기면 다음 줄들이 고아가 되어 파일이 깨진다.
714
+ // 배열 값일 때만, 다음 키나 표 머리글 앞에서는 멈춘다.
715
+ const value = lines[index].slice(keyMatch[0].length);
716
+ let depth = value.trimStart().startsWith("[") ? arrayDepth(value) : 0;
717
+ while (
718
+ depth > 0 &&
719
+ index + 1 < lines.length &&
720
+ !/^\s*(?:[A-Za-z0-9_-]+\s*=|\[\[?\s*[A-Za-z0-9_"'-])/.test(
721
+ lines[index + 1],
722
+ )
723
+ ) {
724
+ index += 1;
725
+ depth += arrayDepth(lines[index]);
726
+ }
699
727
  }
700
728
  index += 1;
701
729
  }
@@ -751,6 +779,29 @@ function syncDenylistKey(client, serverName) {
751
779
  .toLowerCase()}`;
752
780
  }
753
781
 
782
+ // 범위 이름(@scope/name)의 첫 @ 는 이름에 속한다.
783
+ function splitPackageSpec(spec) {
784
+ const match = /^((?:@[^/@\s]+\/)?[^@\s]+)(?:@(\S+))?$/.exec(spec);
785
+ return match ? { name: match[1], version: match[2] ?? null } : null;
786
+ }
787
+
788
+ // 명령과 인자 수가 같고, 다른 자리는 레지스트리의 고정 패키지와 이름만 같을 때 고정 인자를 돌려준다.
789
+ export function pinnedArgs(entry, server) {
790
+ if (entry?.command !== server.command || !Array.isArray(entry.args))
791
+ return null;
792
+ if (entry.args.length !== server.args.length) return null;
793
+ let changed = false;
794
+ for (const [index, want] of server.args.entries()) {
795
+ const have = entry.args[index];
796
+ if (have === want) continue;
797
+ const pin = splitPackageSpec(want);
798
+ if (!pin?.version || typeof have !== "string") return null;
799
+ if (splitPackageSpec(have)?.name !== pin.name) return null;
800
+ changed = true;
801
+ }
802
+ return changed ? [...server.args] : null;
803
+ }
804
+
754
805
  export function buildDesiredServerRecord(name, serverConfig, filePath) {
755
806
  const policy = serverPolicy(serverConfig);
756
807
  if (policy === "stdio") {
@@ -1107,6 +1158,27 @@ function updateCodexConfig(filePath, updates = [], removals = []) {
1107
1158
  if (finalRaw === previousRaw) {
1108
1159
  return { modified: false, filePath: resolvedPath };
1109
1160
  }
1161
+ // 줄 단위 치환이 파일을 깨뜨리거나 관리 키 밖의 값을 지우면 쓰지 않는다.
1162
+ // 파서가 원본부터 못 읽는 표기(파서가 모르는 TOML 1.0 문법)는 판정할 수 없어 예전처럼 쓴다.
1163
+ const toml = tomlParser();
1164
+ try {
1165
+ if (
1166
+ toml &&
1167
+ parsesAsToml(toml, previousRaw) &&
1168
+ !isDeepStrictEqual(
1169
+ withoutManagedServers(toml.parse(previousRaw), updates, removals),
1170
+ withoutManagedServers(toml.parse(finalRaw), updates, removals),
1171
+ )
1172
+ )
1173
+ throw new Error("unmanaged TOML changed");
1174
+ } catch {
1175
+ return {
1176
+ modified: false,
1177
+ filePath: resolvedPath,
1178
+ skipped: true,
1179
+ reason: "toml-rewrite-unsafe",
1180
+ };
1181
+ }
1110
1182
 
1111
1183
  mkdirSync(dirname(resolvedPath), { recursive: true });
1112
1184
  // 헤더 비밀이 들어갈 수 있다. 새 파일은 0600, 기존 파일은 권한이 그대로 유지된다.
@@ -1830,6 +1902,52 @@ export function removeServerFromTargets(name, options = {}) {
1830
1902
  return { actions };
1831
1903
  }
1832
1904
 
1905
+ // packages/remote 는 @iarna/toml 을 의존으로 두지 않아 늦게 불러오고, 없으면 null.
1906
+ function tomlParser() {
1907
+ try {
1908
+ return createRequire(import.meta.url)("@iarna/toml");
1909
+ } catch {
1910
+ return null;
1911
+ }
1912
+ }
1913
+
1914
+ // 갱신한 서버의 관리 키만 뺀다. 남은 값이 원본과 같아야 치환이 사용자 값을 건드리지 않은 것이다.
1915
+ function withoutManagedServers(data, updates, removals) {
1916
+ for (const name of removals) delete data.mcp_servers?.[name];
1917
+ for (const { name } of updates) {
1918
+ const server = data.mcp_servers?.[name];
1919
+ if (!server) continue;
1920
+ for (const key of TOML_MANAGED_KEYS) delete server[key];
1921
+ if (!Object.keys(server).length) delete data.mcp_servers[name];
1922
+ }
1923
+ if (data.mcp_servers && !Object.keys(data.mcp_servers).length)
1924
+ delete data.mcp_servers;
1925
+ return data;
1926
+ }
1927
+
1928
+ function parsesAsToml(toml, raw) {
1929
+ try {
1930
+ toml.parse(raw);
1931
+ return true;
1932
+ } catch {
1933
+ return false;
1934
+ }
1935
+ }
1936
+
1937
+ // 간이 TOML 스캐너는 작은따옴표와 줄 끝 주석을 잘못 읽으므로 보호 판정에 쓸 Codex 항목은 실제 파서로 읽는다.
1938
+ function existingServerEntry(filePath, snapshot, name) {
1939
+ const scanned = snapshot.servers.find((server) => server.name === name);
1940
+ const toml = isCodexConfig(filePath) && tomlParser();
1941
+ if (!toml) return scanned;
1942
+ try {
1943
+ const entry = toml.parse(readFileSync(resolveFilePath(filePath), "utf8"))
1944
+ .mcp_servers?.[name];
1945
+ return entry && { ...entry, args: entry.args ?? [] };
1946
+ } catch {
1947
+ return scanned;
1948
+ }
1949
+ }
1950
+
1833
1951
  export function syncRegistryTargets(options = {}) {
1834
1952
  const registry = options.registry || loadRegistryOrDefault();
1835
1953
  const syncTargets = Array.isArray(options.targets)
@@ -1934,6 +2052,29 @@ export function syncRegistryTargets(options = {}) {
1934
2052
  continue;
1935
2053
  }
1936
2054
 
2055
+ // 사용자가 바꾼 stdio 항목은 덮어쓰지 않는다. 같은 패키지의 버전 차이만 고정 버전으로 맞춘다.
2056
+ const existing = existingServerEntry(target.filePath, snapshot, name);
2057
+ if (
2058
+ serverPolicy(serverConfig) === "stdio" &&
2059
+ typeof existing?.command === "string" &&
2060
+ existing.command &&
2061
+ !(
2062
+ existing.command === serverConfig.command &&
2063
+ JSON.stringify(existing.args) === JSON.stringify(serverConfig.args)
2064
+ ) &&
2065
+ !pinnedArgs(existing, serverConfig)
2066
+ ) {
2067
+ actions.push({
2068
+ type: "sync",
2069
+ filePath: target.filePath,
2070
+ label: target.label,
2071
+ status: "warning",
2072
+ server: name,
2073
+ message: `${name}: 레지스트리와 다른 사용자 항목이라 덮어쓰지 않음 (${[existing.command, ...(existing.args ?? [])].join(" ")})`,
2074
+ });
2075
+ continue;
2076
+ }
2077
+
1937
2078
  updates.push(
1938
2079
  buildDesiredServerRecord(name, serverConfig, target.filePath),
1939
2080
  );
@@ -1950,6 +2091,18 @@ export function syncRegistryTargets(options = {}) {
1950
2091
  continue;
1951
2092
  }
1952
2093
 
2094
+ if (result.reason === "toml-rewrite-unsafe") {
2095
+ actions.push({
2096
+ type: "sync",
2097
+ filePath: target.filePath,
2098
+ label: target.label,
2099
+ status: "warning",
2100
+ message:
2101
+ "줄 단위로 고칠 수 없는 TOML 표기(여러 줄 값 등)라 파일을 바꾸지 않음",
2102
+ });
2103
+ continue;
2104
+ }
2105
+
1953
2106
  actions.push({
1954
2107
  type: "sync",
1955
2108
  filePath: target.filePath,
package/scripts/setup.mjs CHANGED
@@ -35,7 +35,11 @@ import {
35
35
  cleanupAgyHooks,
36
36
  cleanupLegacyHooks,
37
37
  } from "./lib/legacy-hook-cleanup.mjs";
38
- import { cleanupLegacyMcp, cleanupTfxHub } from "./lib/legacy-mcp-cleanup.mjs";
38
+ import {
39
+ cleanupLegacyMcp,
40
+ cleanupTfxHub,
41
+ pinRegistryMcpPackages,
42
+ } from "./lib/legacy-mcp-cleanup.mjs";
39
43
  import {
40
44
  MACHINE_PROFILE_KEYS,
41
45
  parseMachineProfileContent,
@@ -447,7 +451,7 @@ const PSMUX_WINGET_ARGS = [
447
451
  ];
448
452
  const PSMUX_MANUAL_INSTALL = "winget install --exact --id marlocarlo.psmux";
449
453
 
450
- // psmux 설치와 Codex 훅 승인은 사용자 동의 뒤에만 한다. 물을 수 없으면 하지 않고 안내만 남긴다.
454
+ // psmux, tmr, mods 설치와 Codex 훅 승인은 사용자 동의 뒤에만 한다. 물을 수 없으면 하지 않고 안내만 남긴다.
451
455
  export async function runConsentSteps({
452
456
  platform = process.platform,
453
457
  env = process.env,
@@ -459,6 +463,8 @@ export async function runConsentSteps({
459
463
  run = execFileSync,
460
464
  ensureHooks = ensureCodexHooks,
461
465
  offerTmr = offerTmrInstall,
466
+ modsInstall = false,
467
+ offerMods = ensureTrifluxMods,
462
468
  home = _TFX_HOME,
463
469
  log = console.log,
464
470
  warn = console.warn,
@@ -466,6 +472,7 @@ export async function runConsentSteps({
466
472
  const result = {
467
473
  psmux: "not-needed",
468
474
  tmr: "not-needed",
475
+ mods: "not-needed",
469
476
  codexHooks: "skipped",
470
477
  };
471
478
 
@@ -504,6 +511,16 @@ export async function runConsentSteps({
504
511
  warn,
505
512
  });
506
513
 
514
+ result.mods = await offerMods({
515
+ install: modsInstall,
516
+ interactive,
517
+ ask,
518
+ env,
519
+ execFileSyncFn: run,
520
+ log,
521
+ warn,
522
+ });
523
+
507
524
  try {
508
525
  const hooks = ensureHooks({ trust: false });
509
526
  if (hooks?.skipped) return result;
@@ -1292,17 +1309,12 @@ function isProtectedSetupEnv(env = process.env) {
1292
1309
  return env.CI === "true" || Boolean(env.TRIFLUX_TEST_HOME) || isTestRun(env);
1293
1310
  }
1294
1311
 
1295
- export function ensureTrifluxMods({
1296
- install = false,
1297
- env = process.env,
1298
- execFileSyncFn = execFileSync,
1299
- log = console.log,
1300
- warn = console.warn,
1301
- } = {}) {
1302
- if (isProtectedSetupEnv(env) || env.npm_lifecycle_event === "postinstall") {
1303
- return { ok: true, skipped: true, reason: "protected-env" };
1304
- }
1305
- const run = (args) =>
1312
+ const MODS_PLUGIN_ID = "triflux-mods@triflux";
1313
+ export const MODS_UPDATE_COMMAND =
1314
+ "claude plugin marketplace update triflux && claude plugin update triflux-mods@triflux --scope user";
1315
+
1316
+ function claudeCli(env, execFileSyncFn) {
1317
+ return (args) =>
1306
1318
  execFileSyncFn("claude", args, {
1307
1319
  env,
1308
1320
  encoding: "utf8",
@@ -1311,13 +1323,23 @@ export function ensureTrifluxMods({
1311
1323
  windowsHide: true,
1312
1324
  shell: process.platform === "win32",
1313
1325
  });
1326
+ }
1327
+
1328
+ /** mods 설치 상태를 읽기만 한다. setup 과 doctor 가 같은 판정을 쓴다. */
1329
+ export function inspectTrifluxMods({
1330
+ env = process.env,
1331
+ pkgVersion = getPackageVersion(),
1332
+ run = claudeCli(env, execFileSync),
1333
+ } = {}) {
1334
+ if (isProtectedSetupEnv(env) || env.npm_lifecycle_event === "postinstall")
1335
+ return { status: "skipped", reason: "protected-env" };
1314
1336
  let version;
1315
1337
  try {
1316
1338
  version = String(run(["--version"]))
1317
1339
  .trim()
1318
1340
  .match(/^(\d+)\.(\d+)\.(\d+)(?:\s|$)/u);
1319
1341
  } catch {
1320
- return { ok: true, skipped: true, reason: "claude-unavailable" };
1342
+ return { status: "skipped", reason: "claude-unavailable" };
1321
1343
  }
1322
1344
  const [major, minor, patch] = (version?.slice(1) || []).map(Number);
1323
1345
  if (
@@ -1325,32 +1347,91 @@ export function ensureTrifluxMods({
1325
1347
  major > 2 ||
1326
1348
  (major === 2 && (minor > 1 || (minor === 1 && patch >= 287)))
1327
1349
  )
1328
- ) {
1329
- return { ok: true, skipped: true, reason: "unsupported-version" };
1330
- }
1350
+ )
1351
+ return { status: "skipped", reason: "unsupported-version" };
1331
1352
  try {
1332
1353
  const marketplaces = JSON.parse(
1333
1354
  run(["plugin", "marketplace", "list", "--json"]),
1334
1355
  );
1335
- const plugins = JSON.parse(run(["plugin", "list", "--json"]));
1336
- const installed = plugins.some(
1337
- (plugin) => plugin.id === "triflux-mods@triflux",
1356
+ // setup 은 user scope 에만 설치한다. project/local 설치는 다른 프로젝트 것일 수 있어 보지 않는다.
1357
+ const plugin = JSON.parse(run(["plugin", "list", "--json"])).find(
1358
+ (entry) =>
1359
+ entry.id === MODS_PLUGIN_ID && (entry.scope ?? "user") === "user",
1360
+ );
1361
+ const hasMarketplace = marketplaces.some(
1362
+ (marketplace) => marketplace.name === "triflux",
1338
1363
  );
1339
- if (!marketplaces.some((marketplace) => marketplace.name === "triflux")) {
1364
+ // 버전을 모르면 차이로 보지 않는다.
1365
+ const outdated =
1366
+ Boolean(plugin?.version && pkgVersion) && plugin.version !== pkgVersion;
1367
+ return {
1368
+ status: !plugin ? "missing" : outdated ? "outdated" : "current",
1369
+ installedVersion: plugin?.version,
1370
+ pkgVersion,
1371
+ hasMarketplace,
1372
+ };
1373
+ } catch (error) {
1374
+ return { status: "failed", error: _normalizeErrorMessage(error) };
1375
+ }
1376
+ }
1377
+
1378
+ // 설치와 업데이트는 --mods 나 대화형 동의가 있을 때만 한다. 물을 수 없으면 안내만 남긴다.
1379
+ export async function ensureTrifluxMods({
1380
+ install = false,
1381
+ interactive = false,
1382
+ ask = async () => false,
1383
+ env = process.env,
1384
+ pkgVersion = getPackageVersion(),
1385
+ execFileSyncFn = execFileSync,
1386
+ log = console.log,
1387
+ warn = console.warn,
1388
+ } = {}) {
1389
+ const run = claudeCli(env, execFileSyncFn);
1390
+ const mods = inspectTrifluxMods({ env, pkgVersion, run });
1391
+ if (mods.status === "skipped") return "not-needed";
1392
+ if (mods.status === "current") return "present";
1393
+ try {
1394
+ if (mods.status === "failed") throw new Error(mods.error);
1395
+ if (!mods.hasMarketplace)
1340
1396
  run(["plugin", "marketplace", "add", "tellang/triflux"]);
1341
- }
1342
- if (!installed) {
1343
- if (install) {
1344
- run(["plugin", "install", "triflux-mods@triflux"]);
1345
- log("mods 설치 완료: triflux-mods@triflux");
1346
- } else {
1397
+ if (mods.status === "missing") {
1398
+ if (!install && !interactive) {
1347
1399
  log("mods 설치: tfx setup --mods");
1400
+ return "deferred";
1401
+ }
1402
+ if (
1403
+ !install &&
1404
+ !(await ask(
1405
+ "triflux mods 플러그인(HUD band, 서브에이전트 effort 정책)을 설치할까요?",
1406
+ ))
1407
+ ) {
1408
+ log("mods 설치를 건너뜀. 나중에 설치: tfx setup --mods");
1409
+ return "declined";
1348
1410
  }
1411
+ run(["plugin", "install", MODS_PLUGIN_ID]);
1412
+ log(`mods 설치 완료: ${MODS_PLUGIN_ID}`);
1413
+ return "installed";
1349
1414
  }
1350
- return { ok: true, installed: installed || install };
1415
+ const gap = `${mods.installedVersion}, 패키지 ${mods.pkgVersion}`;
1416
+ if (!install && !interactive) {
1417
+ log(`mods 버전이 다르다(설치 ${gap}). 업데이트: ${MODS_UPDATE_COMMAND}`);
1418
+ return "outdated";
1419
+ }
1420
+ if (
1421
+ !install &&
1422
+ !(await ask(`triflux mods 를 업데이트할까요? (설치 ${gap})`))
1423
+ ) {
1424
+ log(`mods 업데이트를 건너뜀. 나중에: ${MODS_UPDATE_COMMAND}`);
1425
+ return "update-declined";
1426
+ }
1427
+ run(["plugin", "marketplace", "update", "triflux"]);
1428
+ // scope 를 빼면 CLI 가 다른 scope 설치를 고를 수 있다.
1429
+ run(["plugin", "update", MODS_PLUGIN_ID, "--scope", "user"]);
1430
+ log("mods 업데이트 완료. Claude Code 를 다시 시작하면 적용된다.");
1431
+ return "updated";
1351
1432
  } catch (error) {
1352
1433
  warn(`[setup] mods 설정 실패: ${_normalizeErrorMessage(error)}`);
1353
- return { ok: false, reason: "setup-failed" };
1434
+ return "failed";
1354
1435
  }
1355
1436
  }
1356
1437
 
@@ -2015,17 +2096,27 @@ export async function runDeferred(stdinData) {
2015
2096
  for (const warning of hubCleanup.warnings) io.log(` ⚠ ${warning}`);
2016
2097
  if (hubCleanup.changed) io.log(" 허브 설정과 실행 흔적 정리");
2017
2098
 
2099
+ const mcpPins = pinRegistryMcpPackages({
2100
+ home: _TFX_HOME,
2101
+ backups: mcpBackups,
2102
+ });
2103
+ for (const warning of mcpPins.warnings) io.log(` ⚠ ${warning}`);
2104
+ if (mcpPins.pinned)
2105
+ io.log(` MCP 패키지 고정 버전 반영: ${mcpPins.pinned}개 항목`);
2106
+
2018
2107
  const pkgVersion = getPackageVersion();
2019
2108
  const marker = readMarker();
2020
2109
  const skillSync = syncSkills();
2021
2110
  for (const warning of skillSync.warnings) io.log(` ⚠ ${warning}`);
2022
2111
  if (!skillSync.ok) return io.result(1);
2023
- ensureTrifluxMods({
2024
- install: argv.includes("--mods"),
2025
- log: (message) => io.log(message),
2026
- warn: (message) => io.log(` ⚠ ${message}`),
2027
- });
2028
- if (pkgVersion && marker?.version === pkgVersion && !isForce) {
2112
+ // --mods 는 동의 단계에서 처리하므로 이미 동기화된 버전이어도 건너뛰지 않는다.
2113
+ const modsInstall = argv.includes("--mods");
2114
+ if (
2115
+ pkgVersion &&
2116
+ marker?.version === pkgVersion &&
2117
+ !isForce &&
2118
+ !modsInstall
2119
+ ) {
2029
2120
  io.log(`setup: skip (v${pkgVersion} already synced)`);
2030
2121
  return io.result(0);
2031
2122
  }
@@ -2213,6 +2304,7 @@ export async function runDeferred(stdinData) {
2213
2304
  }
2214
2305
 
2215
2306
  await runConsentSteps({
2307
+ modsInstall,
2216
2308
  log: (message) => io.log(` ${message}`),
2217
2309
  warn: (message) => io.log(` \x1b[33m⚠\x1b[0m ${message}`),
2218
2310
  });
@@ -14,7 +14,7 @@ argument-hint: "<start|ask|wait|queue|compact|stop|rename|interrupt|probe|list-s
14
14
  ## 전송 경로
15
15
 
16
16
  - Claude `--short` 또는 `--session-id`는 기본 `auto`로 UDS를 먼저 시도한다. `--session`이 있고 입력 미전송이 확인된 UDS 실패라면 tmux로 전환하고 `~/.claude/cache/triflux/tfx-live/bug-reports/uds-fallback-*.json`을 남긴다. 컨텍스트 가드 거부 시에는 전환하지 않는다.
17
- - Codex `ask`는 `codex queue`가 기본이다. 아래 "Codex 메시지 전송" 순서를 따른다. 기존 Codex thread의 UDS 연결은 `--transport uds --thread <id|auto>`를 명시한다.
17
+ - Codex `ask`는 Codex 큐(app-server `thread/queue/add`)가 기본이다. 아래 "Codex 메시지 전송" 순서를 따른다. 기존 Codex thread의 UDS 연결은 `--transport uds --thread <id|auto>`를 명시한다.
18
18
  - daemon 참조가 없는 Claude는 tmux가 기본이다.
19
19
  - UDS 결과는 `matchedCompletion === true`일 때만 `done: true`다. timeout이나 연결 종료는 완료가 아니다.
20
20
  - bridge 선택 순서는 `--bridge`, `$TFX_BRIDGE`, `$TFX_REPO_ROOT/hub/bridge.mjs`, 번들된 `hub/bridge.mjs`다.
@@ -44,7 +44,7 @@ tfx-live stop --cli codex --session cx1
44
44
 
45
45
  ### Codex 메시지 전송
46
46
 
47
- 1. 기본은 `codex queue --thread <UUID>`다. 입력창을 건드리지 않고, 바쁘면 쌓였다가 앞 턴이 끝난 직후 순서대로 들어간다. 유휴 TUI는 약 20초 주기로 큐를 가져간다.
47
+ 1. 기본은 app-server 실험 API `thread/queue/add`로 쌓는 것이다. 프롬프트가 프로세스 인자에 보이지 않는다. 입력창을 건드리지 않고, 바쁘면 쌓였다가 앞 턴이 끝난 직후 순서대로 들어간다. 유휴 TUI는 약 20초 주기로 큐를 가져간다. add가 없는 Codex에서만 `codex queue --thread <UUID> --message`로 보내며, 이때 프롬프트가 argv에 보이고 결과에 `queueVia: "codex-queue-argv"`와 `queueFallbackReason`이 남는다. 평소 값은 `queueVia: "app-server"`다.
48
48
  2. 첫 줄 머리말은 `[from <보낸 세션 이름>] [tfx-live req=<id>]`다. 이름은 `--from`, `$TFX_LIVE_FROM`, Claude 세션 기록, Codex `session_index` 순으로 찾고 없으면 머리말 없이 표식만 붙인다. Codex TUI는 Claude처럼 접어 보여 주지 않고 사용자 입력으로 그대로 표시한다.
49
49
  3. thread는 `--thread UUID`, `start`가 남긴 `@tfx_codex_thread`, Codex 세션 레지스트리, pane 프로세스의 `resume <UUID>` 인자, pane cwd와 같은 rollout 하나 순으로 찾는다. cwd 대응은 그 cwd의 Codex TUI가 하나일 때만 쓰고 exec 워커와 서브에이전트 thread는 뺀다. 이름으로는 보내지 않는다(`codex queue`의 이름 조회가 실패한다).
50
50
  4. tmux 직접 입력은 queue를 못 쓸 때만 쓴다: 원격 호스트, thread를 못 찾음(`start` 밖에서 `resume --last`로 띄운 세션, `start --resume-last`의 `threadId: null`, 같은 cwd의 TUI 여럿 포함), `codex queue` 오류(0.160 미만 포함). 결과에 `transport: "tmux"`, `transportRequested: "queue"`, `fallbackReason`이 남는다. 시간 초과처럼 queue가 이미 쌓았을 수도 있는 오류는 `status: "unknown"`으로 끝내고 재전송하지 않는다.