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 +1 -1
- package/README.md +1 -1
- package/adapters/codex/skills/tfx-live/SKILL.md +2 -2
- package/bin/tfx-live.mjs +5 -1
- package/bin/triflux.mjs +29 -4
- package/hub/team/codex-queue.mjs +49 -3
- package/package.json +1 -1
- package/scripts/lib/legacy-mcp-cleanup.mjs +130 -1
- package/scripts/lib/mcp-guard-engine.mjs +164 -11
- package/scripts/setup.mjs +127 -35
- package/skills/tfx-live/SKILL.md +2 -2
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`는 `
|
|
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
|
|
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`는 `
|
|
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. 기본은
|
|
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`
|
|
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")
|
|
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}`);
|
package/hub/team/codex-queue.mjs
CHANGED
|
@@ -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 로 잠깐 띄워 요청을 보낸다. 큐
|
|
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(
|
|
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
|
@@ -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 =
|
|
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 {
|
|
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
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
1336
|
-
const
|
|
1337
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
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
|
});
|
package/skills/tfx-live/SKILL.md
CHANGED
|
@@ -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`는 `
|
|
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. 기본은
|
|
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"`으로 끝내고 재전송하지 않는다.
|