triflux 10.14.2 → 10.15.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/CLAUDE.md +8 -0
- package/hub/intent.mjs +12 -7
- package/hub/lib/hosts-compat.mjs +66 -3
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/conductor.mjs +11 -0
- package/hub/team/health-probe.mjs +4 -1
- package/hub/team/retry-state-machine.mjs +8 -1
- package/hub/team/swarm-hypervisor.mjs +8 -1
- package/hub/team/swarm-locks.mjs +55 -3
- package/hub/team/tui-remote-adapter.mjs +5 -25
- package/hub/workers/codex-mcp.mjs +17 -2
- package/package.json +1 -1
- package/scripts/__tests__/mcp-guard-engine.test.mjs +17 -4
- package/scripts/headless-guard.mjs +6 -2
- package/scripts/lib/mcp-guard-engine.mjs +3 -4
- package/scripts/setup.mjs +104 -8
- package/scripts/sync-hub-mcp-settings.mjs +80 -5
- package/scripts/tfx-route.sh +46 -39
- package/skills/tfx-remote/SKILL.md +9 -1
- package/skills/tfx-remote-setup/SKILL.md.tmpl +13 -0
- package/skills/tfx-remote-spawn/SKILL.md +9 -0
- package/skills/tfx-remote-spawn/references/hosts.json +33 -8
- package/skills/tfx-remote-spawn/references/hosts.json.bak.20260425_040814 +16 -0
- package/skills/tfx-setup/SKILL.md +6 -2
package/CLAUDE.md
CHANGED
|
@@ -159,3 +159,11 @@ background로 실행한 headless 결과는 **반드시 task-notification 완료
|
|
|
159
159
|
| `.claude/rules/tfx-stack-coexistence.md` | gstack / superpowers / triflux 공존 원칙, 레이어 분리, 의존 방향, 충돌 해소 |
|
|
160
160
|
|
|
161
161
|
Claude Code는 `.claude/rules/*.md` 를 자동 로드한다. Codex CLI는 `@import` 미지원이므로 필요 시 `AGENTS.md` 를 독립 유지한다.
|
|
162
|
+
|
|
163
|
+
## GBrain Configuration (configured by /setup-gbrain)
|
|
164
|
+
- Engine: pglite
|
|
165
|
+
- Config file: ~/.gbrain/config.json (mode 0600)
|
|
166
|
+
- Setup date: 2026-04-25
|
|
167
|
+
- MCP registered: yes (user scope, absolute path)
|
|
168
|
+
- Memory sync: artifacts-only (repo: github.com/tellang/gstack-brain-tellang)
|
|
169
|
+
- Current repo policy: read-write (github.com/tellang/triflux)
|
package/hub/intent.mjs
CHANGED
|
@@ -61,17 +61,22 @@ function _tryCodexClassify(prompt) {
|
|
|
61
61
|
* triflux 특화 의도 카테고리 (10개)
|
|
62
62
|
*/
|
|
63
63
|
export const INTENT_CATEGORIES = {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
// 모델 정책 (2026-04-25):
|
|
65
|
+
// - gpt-5.5 = 메인 (코드 포함 모든 메인 직무, fast tier 가능)
|
|
66
|
+
// - gpt-5.4-mini = 자잘/부가/가성비 (fast tier 가능)
|
|
67
|
+
// - gpt-5.3-codex = escalation 가성비 중간 (fast 미지원)
|
|
68
|
+
// - gpt-5.4 폐기, gpt-5.5 로 격상
|
|
69
|
+
implement: { agent: "executor", mcp: "implement", effort: "gpt55_high" },
|
|
70
|
+
debug: { agent: "debugger", mcp: "implement", effort: "gpt55_xhigh" },
|
|
71
|
+
analyze: { agent: "analyst", mcp: "analyze", effort: "gpt55_xhigh" },
|
|
72
|
+
design: { agent: "architect", mcp: "analyze", effort: "gpt55_xhigh" },
|
|
73
|
+
review: { agent: "code-reviewer", mcp: "review", effort: "gpt55_high" },
|
|
69
74
|
document: { agent: "writer", mcp: "docs", effort: "pro" },
|
|
70
|
-
research: { agent: "scientist", mcp: "analyze", effort: "
|
|
75
|
+
research: { agent: "scientist", mcp: "analyze", effort: "gpt55_high" },
|
|
71
76
|
"quick-fix": {
|
|
72
77
|
agent: "build-fixer",
|
|
73
78
|
mcp: "implement",
|
|
74
|
-
effort: "
|
|
79
|
+
effort: "gpt55_low",
|
|
75
80
|
},
|
|
76
81
|
explain: { agent: "writer", mcp: "docs", effort: "flash" },
|
|
77
82
|
test: { agent: "test-engineer", mcp: null, effort: null },
|
package/hub/lib/hosts-compat.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
3
4
|
|
|
4
5
|
const HOSTS_LOCATIONS = [
|
|
5
6
|
["references", "hosts.json"],
|
|
@@ -7,13 +8,70 @@ const HOSTS_LOCATIONS = [
|
|
|
7
8
|
["packages", "triflux", "references", "hosts.json"],
|
|
8
9
|
];
|
|
9
10
|
|
|
11
|
+
let migrated = false;
|
|
12
|
+
|
|
10
13
|
function readJsonFile(path) {
|
|
11
14
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
export function userStateHostsPath() {
|
|
18
|
+
if (process.env.TFX_HOSTS_USER_STATE_DISABLE === "1") return null;
|
|
19
|
+
if (process.env.TFX_HOSTS_USER_STATE) return process.env.TFX_HOSTS_USER_STATE;
|
|
20
|
+
if (process.platform === "win32") {
|
|
21
|
+
return join(
|
|
22
|
+
process.env.APPDATA || join(homedir(), "AppData", "Roaming"),
|
|
23
|
+
"triflux",
|
|
24
|
+
"hosts.json",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return join(homedir(), ".config", "triflux", "hosts.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
function candidatePaths(repoRoot) {
|
|
15
31
|
const root = repoRoot || process.cwd();
|
|
16
|
-
|
|
32
|
+
const repoRootCandidates = HOSTS_LOCATIONS.map((segments) =>
|
|
33
|
+
join(root, ...segments),
|
|
34
|
+
);
|
|
35
|
+
const userPath = userStateHostsPath();
|
|
36
|
+
return userPath ? [userPath, ...repoRootCandidates] : repoRootCandidates;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function migrateLegacyHosts(repoRoot) {
|
|
40
|
+
const to = userStateHostsPath();
|
|
41
|
+
let from = null;
|
|
42
|
+
if (!to) {
|
|
43
|
+
return {
|
|
44
|
+
migrated: false,
|
|
45
|
+
from: null,
|
|
46
|
+
to: null,
|
|
47
|
+
reason: "user-state-disabled",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
if (existsSync(to)) {
|
|
52
|
+
return { migrated: false, from: null, to, reason: "already-exists" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const root = repoRoot || process.cwd();
|
|
56
|
+
from =
|
|
57
|
+
HOSTS_LOCATIONS.map((segments) => join(root, ...segments)).find((path) =>
|
|
58
|
+
existsSync(path),
|
|
59
|
+
) || null;
|
|
60
|
+
if (!from) {
|
|
61
|
+
return { migrated: false, from: null, to, reason: "not-found" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
65
|
+
copyFileSync(from, to);
|
|
66
|
+
return { migrated: true, from, to };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return {
|
|
69
|
+
migrated: false,
|
|
70
|
+
from,
|
|
71
|
+
to,
|
|
72
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
17
75
|
}
|
|
18
76
|
|
|
19
77
|
function canonicalOs(rawOs) {
|
|
@@ -123,6 +181,11 @@ export function normalizeHost(rawHost = {}, name = "") {
|
|
|
123
181
|
}
|
|
124
182
|
|
|
125
183
|
export function readHosts(repoRoot) {
|
|
184
|
+
if (!migrated) {
|
|
185
|
+
migrateLegacyHosts(repoRoot);
|
|
186
|
+
migrated = true;
|
|
187
|
+
}
|
|
188
|
+
|
|
126
189
|
for (const path of candidatePaths(repoRoot)) {
|
|
127
190
|
if (!existsSync(path)) continue;
|
|
128
191
|
const parsed = readJsonFile(path);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// hub/team/check-mcp-hub.mjs — health-probe L2 용 hub /health ping 체커.
|
|
2
|
+
// health-probe.mjs 의 checkMcp 로 주입되어 `mcp_initializing` state 판정에 사용.
|
|
3
|
+
// hub /health 가 200 이면 OK (MCP transport 인프라 살아있음), 그 외는 fail.
|
|
4
|
+
|
|
5
|
+
const DEFAULT_HUB_URL = "http://127.0.0.1:27888";
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 3000;
|
|
7
|
+
|
|
8
|
+
function resolveHubHealthUrl(hubUrl) {
|
|
9
|
+
const base = hubUrl || process.env.TFX_HUB_URL || DEFAULT_HUB_URL;
|
|
10
|
+
// `/mcp` suffix 제거 (triflux convention — bridge.mjs:54, hub-client.mjs,
|
|
11
|
+
// lead-control.mjs, session-sync.mjs 전부 TFX_HUB_URL 을 MCP transport URL
|
|
12
|
+
// 로 쓰고 base URL 이 필요할 때 `/mcp$` 를 strip 한다). 제거하지 않으면
|
|
13
|
+
// `.../mcp/health` 가 되어 hub 가 404 로 응답 → L2 영구 fail → heartbeat
|
|
14
|
+
// 이 지속적으로 `mcp_initializing` 로 grace — #173 Codex P2 review.
|
|
15
|
+
return base.replace(/\/mcp\/?$/, "").replace(/\/+$/, "") + "/health";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hub /health 기반 L2 checker factory.
|
|
20
|
+
* @param {object} [opts]
|
|
21
|
+
* @param {string} [opts.hubUrl] — override. 미지정 시 TFX_HUB_URL env 또는 default.
|
|
22
|
+
* @param {number} [opts.timeoutMs=3000] — fetch timeout (ms).
|
|
23
|
+
* @param {typeof fetch} [opts.fetchFn] — fetch 오버라이드 (테스트용).
|
|
24
|
+
* @returns {() => Promise<boolean>} — true = hub healthy, false = degraded/down/timeout.
|
|
25
|
+
*/
|
|
26
|
+
export function createHubHealthChecker(opts = {}) {
|
|
27
|
+
const url = resolveHubHealthUrl(opts.hubUrl);
|
|
28
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs)
|
|
29
|
+
? opts.timeoutMs
|
|
30
|
+
: DEFAULT_TIMEOUT_MS;
|
|
31
|
+
const fetchFn = opts.fetchFn || globalThis.fetch;
|
|
32
|
+
|
|
33
|
+
return async function checkMcpHubHealth() {
|
|
34
|
+
if (typeof fetchFn !== "function") return false;
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetchFn(url, {
|
|
39
|
+
method: "GET",
|
|
40
|
+
signal: controller.signal,
|
|
41
|
+
});
|
|
42
|
+
return res.ok === true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
} finally {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
package/hub/team/conductor.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { createRegistry } from "../../mesh/mesh-registry.mjs";
|
|
|
22
22
|
import { broker } from "../account-broker.mjs";
|
|
23
23
|
import { execFile, spawn } from "../lib/spawn-trace.mjs";
|
|
24
24
|
import { killProcess } from "../platform.mjs";
|
|
25
|
+
import { createHubHealthChecker } from "./check-mcp-hub.mjs";
|
|
25
26
|
import { createConductorMeshBridge } from "./conductor-mesh-bridge.mjs";
|
|
26
27
|
import {
|
|
27
28
|
ensureConductorRegistry,
|
|
@@ -700,6 +701,16 @@ export function createConductor(opts = {}) {
|
|
|
700
701
|
// opt-out: TFX_PROBE_WRITE_STATE=0 명시.
|
|
701
702
|
writeStateFile:
|
|
702
703
|
probeOpts.writeStateFile ?? process.env.TFX_PROBE_WRITE_STATE !== "0",
|
|
704
|
+
// #168 P3: L2 (hub /health) checker wiring. probeOpts 가 명시 주입하면
|
|
705
|
+
// 그걸 우선. 아니면 TFX_PROBE_L2=0 로 opt-out. 나머지 경우 hub URL 기반
|
|
706
|
+
// default checker 주입 → deriveState 가 `mcp_initializing` 라벨을 생산 →
|
|
707
|
+
// heartbeat (read_probe_state) 가 probe-grace 분기로 감.
|
|
708
|
+
enableL2: probeOpts.enableL2 ?? process.env.TFX_PROBE_L2 !== "0",
|
|
709
|
+
checkMcp:
|
|
710
|
+
probeOpts.checkMcp ||
|
|
711
|
+
(process.env.TFX_PROBE_L2 === "0"
|
|
712
|
+
? undefined
|
|
713
|
+
: createHubHealthChecker({ hubUrl: process.env.TFX_HUB_URL })),
|
|
703
714
|
onProbe: (result) => handleProbeResult(session, result),
|
|
704
715
|
},
|
|
705
716
|
);
|
|
@@ -28,7 +28,10 @@ export const PROBE_DEFAULTS = Object.freeze({
|
|
|
28
28
|
l1ThresholdMs: 30_000,
|
|
29
29
|
l2ThresholdMs: 30_000,
|
|
30
30
|
l3ThresholdMs: 120_000,
|
|
31
|
-
|
|
31
|
+
// #168 P3: default off → on. checkMcp 미주입 시에도 probeL2 가 skip 반환하므로
|
|
32
|
+
// safe. conductor wiring 이 checkMcp 를 주입하면 실제 L2 판정이 활성화된다.
|
|
33
|
+
// opt-out: TFX_PROBE_L2=0 (conductor 에서 false 주입).
|
|
34
|
+
enableL2: true,
|
|
32
35
|
writeStateFile: false,
|
|
33
36
|
stateDir: join(tmpdir(), "tfx-probe"),
|
|
34
37
|
});
|
|
@@ -38,10 +38,17 @@ export const MODES = Object.freeze({
|
|
|
38
38
|
ESCALATE: "auto-escalate",
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
// Escalation chain (2026-04-25 정책):
|
|
42
|
+
// 1. gpt-5.4-mini — 비용 최저, fast tier, 단순 task 대부분 해결
|
|
43
|
+
// 2. gpt-5.3-codex — 가성비 중간, code specialized (Plus/free 모두 OK, fast 미지원)
|
|
44
|
+
// 3. gpt-5.5 — top reasoning + 코드 강함, fast tier
|
|
45
|
+
// 4. claude opus-4-7 — 최종 수단
|
|
46
|
+
// sonnet-4-6 단계는 제거: gpt-5.5 가 코드/추론/비용 모두 우위 + 5.3-codex 가성비
|
|
47
|
+
// 단계가 더 적합한 중간 격상.
|
|
41
48
|
const DEFAULT_ESCALATION_CHAIN = Object.freeze([
|
|
42
49
|
Object.freeze({ cli: "codex", model: "gpt-5.4-mini" }),
|
|
50
|
+
Object.freeze({ cli: "codex", model: "gpt-5.3-codex" }),
|
|
43
51
|
Object.freeze({ cli: "codex", model: "gpt-5.5" }),
|
|
44
|
-
Object.freeze({ cli: "claude", model: "sonnet-4-6" }),
|
|
45
52
|
Object.freeze({ cli: "claude", model: "opus-4-7" }),
|
|
46
53
|
]);
|
|
47
54
|
|
|
@@ -855,7 +855,14 @@ export function createSwarmHypervisor(opts) {
|
|
|
855
855
|
* @returns {{ ok: boolean, violations: Array }}
|
|
856
856
|
*/
|
|
857
857
|
function validateResult(shardName, changedFiles) {
|
|
858
|
-
|
|
858
|
+
// Pass the shard's own lease so validateChanges can flag out-of-lease
|
|
859
|
+
// writes to distribution-critical paths (regression of #115/#34 —
|
|
860
|
+
// recovery patches were carrying `+++ /dev/null` deletions of
|
|
861
|
+
// .claude-plugin/marketplace.json, undetected by lease-only checks).
|
|
862
|
+
const ownLease = plan.leaseMap.get(shardName) || [];
|
|
863
|
+
const violations = lockManager.validateChanges(shardName, changedFiles, {
|
|
864
|
+
ownLease,
|
|
865
|
+
});
|
|
859
866
|
|
|
860
867
|
eventLog.append("validate_result", {
|
|
861
868
|
shard: shardName,
|
package/hub/team/swarm-locks.mjs
CHANGED
|
@@ -8,6 +8,26 @@ import { dirname, relative, resolve } from "node:path";
|
|
|
8
8
|
|
|
9
9
|
const LOCK_TTL_MS = 10 * 60_000; // 10 minutes default TTL
|
|
10
10
|
|
|
11
|
+
// Distribution-critical paths. A swarm worker that modifies these without
|
|
12
|
+
// having them explicitly listed in its shard lease is almost certainly a
|
|
13
|
+
// recovery-patch fallout (worker exited before commit, hypervisor saved
|
|
14
|
+
// patch with destructive `+++ /dev/null` deletions). Block by default;
|
|
15
|
+
// override via validateChanges options.sensitiveDeny.
|
|
16
|
+
//
|
|
17
|
+
// Paths use POSIX separators (matches normalizePath output).
|
|
18
|
+
const SENSITIVE_PATH_PREFIXES = [
|
|
19
|
+
".claude-plugin/",
|
|
20
|
+
"bin/",
|
|
21
|
+
".github/workflows/",
|
|
22
|
+
];
|
|
23
|
+
const SENSITIVE_PATH_FILES = [
|
|
24
|
+
".gitignore",
|
|
25
|
+
".npmignore",
|
|
26
|
+
"package.json",
|
|
27
|
+
"package-lock.json",
|
|
28
|
+
"biome.json",
|
|
29
|
+
];
|
|
30
|
+
|
|
11
31
|
/**
|
|
12
32
|
* Swarm lock manager factory.
|
|
13
33
|
* @param {object} [opts]
|
|
@@ -205,20 +225,52 @@ export function createSwarmLocks(opts = {}) {
|
|
|
205
225
|
/**
|
|
206
226
|
* Validate a set of changed files against the lease map.
|
|
207
227
|
* Returns all violations found.
|
|
228
|
+
*
|
|
229
|
+
* Beyond the basic cross-lease check, when `options.ownLease` is supplied
|
|
230
|
+
* (the caller's known lease set), this also flags out-of-lease writes to
|
|
231
|
+
* distribution-critical paths — e.g. recovery-patch fallouts where a
|
|
232
|
+
* worker's recorded diff carries `+++ /dev/null` deletions of files like
|
|
233
|
+
* `.claude-plugin/marketplace.json` (regression of #115 / #34).
|
|
234
|
+
*
|
|
235
|
+
* `options.ownLease` is opt-in to preserve existing callers; when omitted,
|
|
236
|
+
* legacy behavior (cross-lease check only) is kept.
|
|
237
|
+
*
|
|
208
238
|
* @param {string} workerId
|
|
209
239
|
* @param {string[]} changedFiles
|
|
210
|
-
* @
|
|
240
|
+
* @param {{ ownLease?: string[], sensitiveDeny?: { prefixes?: string[], files?: string[] } }} [options]
|
|
241
|
+
* @returns {Array<{ file: string, holder?: string, kind: "other-lease" | "sensitive-out-of-lease" }>}
|
|
211
242
|
*/
|
|
212
|
-
function validateChanges(workerId, changedFiles) {
|
|
243
|
+
function validateChanges(workerId, changedFiles, options = {}) {
|
|
213
244
|
pruneExpired();
|
|
214
245
|
const violations = [];
|
|
246
|
+
const ownLeaseSet = Array.isArray(options.ownLease)
|
|
247
|
+
? new Set(options.ownLease.map((path) => normalizePath(path)))
|
|
248
|
+
: null;
|
|
249
|
+
const sensitivePrefixes =
|
|
250
|
+
options.sensitiveDeny?.prefixes ?? SENSITIVE_PATH_PREFIXES;
|
|
251
|
+
const sensitiveFiles = new Set(
|
|
252
|
+
options.sensitiveDeny?.files ?? SENSITIVE_PATH_FILES,
|
|
253
|
+
);
|
|
215
254
|
|
|
216
255
|
for (const file of changedFiles) {
|
|
217
256
|
const path = normalizePath(file);
|
|
218
257
|
const entry = locks.get(path);
|
|
219
258
|
|
|
220
259
|
if (entry && entry.workerId !== workerId && !isExpired(entry)) {
|
|
221
|
-
violations.push({ file, holder: entry.workerId });
|
|
260
|
+
violations.push({ file, holder: entry.workerId, kind: "other-lease" });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Sensitive-path guard only runs when caller supplied ownLease.
|
|
265
|
+
// We only flag when the worker did NOT explicitly lease the file —
|
|
266
|
+
// an explicit lease means the shard intentionally owns it.
|
|
267
|
+
if (ownLeaseSet && !ownLeaseSet.has(path)) {
|
|
268
|
+
const isSensitive =
|
|
269
|
+
sensitiveFiles.has(path) ||
|
|
270
|
+
sensitivePrefixes.some((prefix) => path.startsWith(prefix));
|
|
271
|
+
if (isSensitive) {
|
|
272
|
+
violations.push({ file, kind: "sensitive-out-of-lease" });
|
|
273
|
+
}
|
|
222
274
|
}
|
|
223
275
|
}
|
|
224
276
|
|
|
@@ -5,16 +5,12 @@
|
|
|
5
5
|
// 완료/실패 시 notify.mjs 자동 호출.
|
|
6
6
|
|
|
7
7
|
import { EventEmitter } from "node:events";
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
11
8
|
|
|
9
|
+
import { readHosts } from "../lib/hosts-compat.mjs";
|
|
12
10
|
import { STATES } from "./conductor.mjs";
|
|
13
11
|
|
|
14
12
|
// ── 상수 ─────────────────────────────────────────────────────────────────────
|
|
15
13
|
|
|
16
|
-
const HOSTS_JSON_REL = "../../references/hosts.json";
|
|
17
|
-
|
|
18
14
|
const CONDUCTOR_STATE_TO_TUI_STATUS = Object.freeze({
|
|
19
15
|
[STATES.INIT]: "pending",
|
|
20
16
|
[STATES.STARTING]: "pending",
|
|
@@ -31,21 +27,6 @@ const SESSION_PREFIX = "tfx-spawn-";
|
|
|
31
27
|
|
|
32
28
|
// ── 유틸 ─────────────────────────────────────────────────────────────────────
|
|
33
29
|
|
|
34
|
-
function loadHostsJson(hostsJsonPath) {
|
|
35
|
-
try {
|
|
36
|
-
const raw = readFileSync(hostsJsonPath, "utf8");
|
|
37
|
-
return JSON.parse(raw);
|
|
38
|
-
} catch {
|
|
39
|
-
return { hosts: {} };
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function resolveHostsJsonPath(overridePath) {
|
|
44
|
-
if (overridePath) return overridePath;
|
|
45
|
-
const thisDir = fileURLToPath(new URL(".", import.meta.url));
|
|
46
|
-
return join(thisDir, HOSTS_JSON_REL);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
30
|
function resolveSshUser(hostsData, host) {
|
|
50
31
|
if (!host || !hostsData?.hosts) return null;
|
|
51
32
|
return hostsData.hosts[host]?.ssh_user || null;
|
|
@@ -144,7 +125,7 @@ function buildWatcherOnlyWorkerData(watcherRecord, hostsData) {
|
|
|
144
125
|
* @param {object} opts.conductor — createConductor() 인스턴스
|
|
145
126
|
* @param {object} [opts.watcher] — createRemoteWatcher() 인스턴스 (nullable)
|
|
146
127
|
* @param {object} [opts.notifier] — createNotifier() 인스턴스 (nullable)
|
|
147
|
-
* @param {string} [opts.
|
|
128
|
+
* @param {string} [opts.repoRoot] — hosts fallback 탐색용 repo root override
|
|
148
129
|
* @param {number} [opts.pollMs=10000] — conductor snapshot 폴링 간격
|
|
149
130
|
* @param {object} [opts.deps] — 테스트용 의존성 주입
|
|
150
131
|
* @returns {{ start, stop, getWorkers, on, off }}
|
|
@@ -160,14 +141,13 @@ export function createRemoteAdapter(opts = {}) {
|
|
|
160
141
|
|
|
161
142
|
if (!conductor) throw new Error("conductor is required");
|
|
162
143
|
|
|
163
|
-
const
|
|
164
|
-
const loadHosts = deps.loadHostsJson || loadHostsJson;
|
|
144
|
+
const loadHosts = deps.readHosts || readHosts;
|
|
165
145
|
const setIntervalFn = deps.setInterval || setInterval;
|
|
166
146
|
const clearIntervalFn = deps.clearInterval || clearInterval;
|
|
167
147
|
const _nowFn = deps.now || Date.now;
|
|
168
148
|
|
|
169
149
|
const emitter = new EventEmitter();
|
|
170
|
-
let hostsData = loadHosts(
|
|
150
|
+
let hostsData = loadHosts(opts.repoRoot);
|
|
171
151
|
let workers = new Map();
|
|
172
152
|
let pollHandle = null;
|
|
173
153
|
let running = false;
|
|
@@ -343,7 +323,7 @@ export function createRemoteAdapter(opts = {}) {
|
|
|
343
323
|
running = true;
|
|
344
324
|
|
|
345
325
|
// hosts.json 리로드
|
|
346
|
-
hostsData = loadHosts(
|
|
326
|
+
hostsData = loadHosts(opts.repoRoot);
|
|
347
327
|
|
|
348
328
|
// conductor stateChange 구독
|
|
349
329
|
conductor.on("stateChange", handleStateChange);
|
|
@@ -533,13 +533,28 @@ export async function runCodexMcpCli(argv = process.argv.slice(2)) {
|
|
|
533
533
|
|
|
534
534
|
try {
|
|
535
535
|
const result = await worker.execute(options.prompt, options);
|
|
536
|
-
|
|
536
|
+
const hasOutput =
|
|
537
|
+
typeof result.output === "string" && result.output.trim().length > 0;
|
|
538
|
+
if (hasOutput) {
|
|
537
539
|
process.stdout.write(result.output);
|
|
538
540
|
if (!result.output.endsWith("\n")) {
|
|
539
541
|
process.stdout.write("\n");
|
|
540
542
|
}
|
|
541
543
|
}
|
|
542
|
-
|
|
544
|
+
// Silent-success regression guard (codex 0.124.0+):
|
|
545
|
+
// codex MCP can return exitCode=0 with empty/whitespace `output` when the
|
|
546
|
+
// result-extraction path fails to read the assistant content from the MCP
|
|
547
|
+
// response. Without this guard, tfx-route.sh sees STDOUT_LOG=0 bytes and
|
|
548
|
+
// treats it as success — caller gets nothing back.
|
|
549
|
+
// Promote to a transport failure so the wrapper falls back to `codex exec`.
|
|
550
|
+
if (!hasOutput && result.exitCode === 0) {
|
|
551
|
+
console.error(
|
|
552
|
+
"[codex-mcp] WARNING: empty output with exit 0 — treating as transport failure (codex 0.124.0 silent-flush regression). Wrapper will fall back to exec path.",
|
|
553
|
+
);
|
|
554
|
+
process.exitCode = CODEX_MCP_TRANSPORT_EXIT_CODE;
|
|
555
|
+
} else {
|
|
556
|
+
process.exitCode = result.exitCode;
|
|
557
|
+
}
|
|
543
558
|
} catch (error) {
|
|
544
559
|
const lines = [error instanceof Error ? error.message : String(error)];
|
|
545
560
|
if (error instanceof CodexMcpTransportError && error.stderr) {
|
package/package.json
CHANGED
|
@@ -101,14 +101,16 @@ describe("mcp guard engine", () => {
|
|
|
101
101
|
);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
it("replaces stdio MCP entries with tfx-hub and writes a backup", () => {
|
|
104
|
+
it("replaces stdio MCP entries with tfx-hub and writes a backup (TFX_HUB_PORT env overrides)", () => {
|
|
105
105
|
const homeDir = createHomeDir();
|
|
106
106
|
withHome(homeDir);
|
|
107
|
+
process.env.TFX_HUB_PORT = "30123";
|
|
107
108
|
|
|
109
|
+
// hub.pid port 는 무시되어야 한다 (PR #158: pid = host hint only).
|
|
108
110
|
const pidPath = join(homeDir, ".claude", "cache", "tfx-hub", "hub.pid");
|
|
109
111
|
writeFileSync(
|
|
110
112
|
pidPath,
|
|
111
|
-
JSON.stringify({ host: "127.0.0.1", port:
|
|
113
|
+
JSON.stringify({ host: "127.0.0.1", port: 40404 }),
|
|
112
114
|
"utf8",
|
|
113
115
|
);
|
|
114
116
|
|
|
@@ -141,9 +143,18 @@ describe("mcp guard engine", () => {
|
|
|
141
143
|
assert.equal(Object.hasOwn(updated.mcpServers, "unsafe-stdio"), false);
|
|
142
144
|
});
|
|
143
145
|
|
|
144
|
-
it("uses
|
|
146
|
+
it("uses TFX_HUB_PORT env as single source when resolving Hub URL", () => {
|
|
145
147
|
const homeDir = createHomeDir();
|
|
146
148
|
withHome(homeDir);
|
|
149
|
+
process.env.TFX_HUB_PORT = "29991";
|
|
150
|
+
|
|
151
|
+
assert.equal(resolveHubUrl(), "http://127.0.0.1:29991/mcp");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("ignores hub.pid port (pid is host hint only, PR #158 policy)", () => {
|
|
155
|
+
const homeDir = createHomeDir();
|
|
156
|
+
withHome(homeDir);
|
|
157
|
+
delete process.env.TFX_HUB_PORT;
|
|
147
158
|
|
|
148
159
|
writeFileSync(
|
|
149
160
|
join(homeDir, ".claude", "cache", "tfx-hub", "hub.pid"),
|
|
@@ -151,6 +162,8 @@ describe("mcp guard engine", () => {
|
|
|
151
162
|
"utf8",
|
|
152
163
|
);
|
|
153
164
|
|
|
154
|
-
|
|
165
|
+
// env 없음 + hub.pid port 존재 → registry/default 27888 fallback.
|
|
166
|
+
// pid port cascade 가 제거되어 29991 이 쓰이면 안 됨.
|
|
167
|
+
assert.equal(resolveHubUrl(), "http://127.0.0.1:27888/mcp");
|
|
155
168
|
});
|
|
156
169
|
});
|
|
@@ -277,11 +277,15 @@ async function main() {
|
|
|
277
277
|
cmdSanitized,
|
|
278
278
|
);
|
|
279
279
|
} else {
|
|
280
|
+
// $()/${} 와 eval 직후 첫 토큰만 검사한다.
|
|
281
|
+
// .* 매칭은 `result=$(grep "codex exec" file)` 같은 grep 인자 패턴까지
|
|
282
|
+
// 차단하는 오탐을 일으킨다. 진짜 위협은 `$(codex exec ...)` 처럼
|
|
283
|
+
// command substitution / eval 의 첫 명령이 codex/gemini 인 경우뿐이다.
|
|
280
284
|
hasDirectCli =
|
|
281
|
-
/\beval\
|
|
285
|
+
/\beval\s+(?:["']\s*)?(codex\s+exec|gemini\s+(-p|--prompt))\b/i.test(
|
|
282
286
|
cmdSanitized,
|
|
283
287
|
) ||
|
|
284
|
-
/\$[({]
|
|
288
|
+
/\$[({]\s*(codex\s+exec|gemini\s+(-p|--prompt))\b/i.test(
|
|
285
289
|
cmdSanitized,
|
|
286
290
|
);
|
|
287
291
|
}
|
|
@@ -851,14 +851,13 @@ export function resolveHubUrl() {
|
|
|
851
851
|
: DEFAULT_HUB_PATH,
|
|
852
852
|
};
|
|
853
853
|
|
|
854
|
+
// PR #158 정책: port = TFX_HUB_PORT env (없으면 registry/default 27888) single source.
|
|
855
|
+
// hub.pid 는 loopback host 힌트 전용. 과거 pid port cascade 는 오염된 port 영속화의
|
|
856
|
+
// 원인이었고 hub-ensure.resolveHubTarget 에서 이미 제거됨. 여기도 일관 적용.
|
|
854
857
|
const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
855
858
|
if (existsSync(hubPidPath)) {
|
|
856
859
|
try {
|
|
857
860
|
const info = readJsonFile(hubPidPath);
|
|
858
|
-
if (!envPort) {
|
|
859
|
-
const pidPort = Number(info?.port);
|
|
860
|
-
if (Number.isFinite(pidPort) && pidPort > 0) target.port = pidPort;
|
|
861
|
-
}
|
|
862
861
|
if (typeof info?.host === "string") {
|
|
863
862
|
const host = info.host.trim();
|
|
864
863
|
if (LOOPBACK_HOSTS.has(host)) target.host = host;
|
package/scripts/setup.mjs
CHANGED
|
@@ -117,6 +117,11 @@ const REQUIRED_CODEX_PROFILES = [
|
|
|
117
117
|
];
|
|
118
118
|
|
|
119
119
|
const HUD_SYNC_EXCLUDES = new Set(["omc-hud.mjs", "omc-hud.mjs.bak"]);
|
|
120
|
+
const SETUP_USER_STATE_FILES = new Set(["hosts.json"]);
|
|
121
|
+
|
|
122
|
+
function isSetupUserStateFile(fileName) {
|
|
123
|
+
return SETUP_USER_STATE_FILES.has(fileName);
|
|
124
|
+
}
|
|
120
125
|
|
|
121
126
|
/**
|
|
122
127
|
* scripts/lib/*.mjs 자동 스캔.
|
|
@@ -409,6 +414,7 @@ function syncAliasedSkillDir(srcDir, dstDir, { alias, source }) {
|
|
|
409
414
|
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
410
415
|
const srcPath = join(srcDir, entry.name);
|
|
411
416
|
const dstPath = join(dstDir, entry.name);
|
|
417
|
+
if (isSetupUserStateFile(entry.name)) continue;
|
|
412
418
|
|
|
413
419
|
if (entry.isDirectory()) {
|
|
414
420
|
count += syncAliasedSkillDir(srcPath, dstPath, { alias, source });
|
|
@@ -762,6 +768,64 @@ function buildWindowsHubAutostartCommand({
|
|
|
762
768
|
].join(" ");
|
|
763
769
|
}
|
|
764
770
|
|
|
771
|
+
// #161 P2: schtasks /Query 실패 시 stderr 를 해석해 미등록/권한거부/기타 실패를 구분한다.
|
|
772
|
+
// 기존 구현은 stdio=ignore + catch 후 항상 registered:false 였기 때문에
|
|
773
|
+
// Access Denied 같은 해결 가능한 문제가 "미등록" 으로 묻혔다.
|
|
774
|
+
const WINDOWS_SCHTASKS_NOT_FOUND_PATTERNS = [
|
|
775
|
+
"cannot find the file",
|
|
776
|
+
"does not exist",
|
|
777
|
+
"지정된 파일",
|
|
778
|
+
"찾을 수 없",
|
|
779
|
+
];
|
|
780
|
+
const WINDOWS_SCHTASKS_ACCESS_DENIED_PATTERNS = [
|
|
781
|
+
"access is denied",
|
|
782
|
+
"access denied",
|
|
783
|
+
"permission",
|
|
784
|
+
"액세스가 거부",
|
|
785
|
+
"권한",
|
|
786
|
+
];
|
|
787
|
+
|
|
788
|
+
// #161 P3: schtasks /TR 인자는 실질적으로 262자 미만으로 제한된다.
|
|
789
|
+
// 초과 시 Create 자체는 성공해도 task 실행에서 인자 잘림/실행 실패 재발.
|
|
790
|
+
// 따라서 Create 전에 사전 검증해 조기 실패를 보장한다.
|
|
791
|
+
const SCHTASKS_TR_MAX_LENGTH = 261;
|
|
792
|
+
|
|
793
|
+
// #161 P3: /TR 길이 검증 공용 함수.
|
|
794
|
+
// schtasks 는 Windows 내부에서 wide-char 문자 수로 제한하므로 UTF-8 byte 가 아닌
|
|
795
|
+
// JavaScript string .length (UTF-16 code units) 기준으로 비교한다.
|
|
796
|
+
// Codex Round 1 P1 반영: UTF-8 byte 검증은 한글 경로에서 정상 명령을 오차단했다
|
|
797
|
+
// (예: ~218자 한글 경로 = 578 bytes → false positive throw).
|
|
798
|
+
// Codex Round 3 P2 반영: 테스트가 실행 경로와 동일한 이 함수를 exercise 하므로
|
|
799
|
+
// 내부 구현이 회귀해 byte 기반으로 돌아가면 테스트가 즉시 포착한다.
|
|
800
|
+
function validateSchtasksTrLength(command) {
|
|
801
|
+
const commandChars = command.length;
|
|
802
|
+
if (commandChars > SCHTASKS_TR_MAX_LENGTH) {
|
|
803
|
+
throw new Error(
|
|
804
|
+
`schtasks /TR 인자가 ${SCHTASKS_TR_MAX_LENGTH} 문자를 초과합니다 ` +
|
|
805
|
+
`(${commandChars} chars): ${command}`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function classifySchtasksStderr(stderr) {
|
|
811
|
+
const lower = String(stderr || "").toLowerCase();
|
|
812
|
+
if (
|
|
813
|
+
WINDOWS_SCHTASKS_NOT_FOUND_PATTERNS.some((p) =>
|
|
814
|
+
lower.includes(p.toLowerCase()),
|
|
815
|
+
)
|
|
816
|
+
) {
|
|
817
|
+
return "not_registered";
|
|
818
|
+
}
|
|
819
|
+
if (
|
|
820
|
+
WINDOWS_SCHTASKS_ACCESS_DENIED_PATTERNS.some((p) =>
|
|
821
|
+
lower.includes(p.toLowerCase()),
|
|
822
|
+
)
|
|
823
|
+
) {
|
|
824
|
+
return "access_denied";
|
|
825
|
+
}
|
|
826
|
+
return "unknown";
|
|
827
|
+
}
|
|
828
|
+
|
|
765
829
|
function getWindowsHubAutostartStatus({
|
|
766
830
|
taskName = WINDOWS_HUB_AUTOSTART_TASK,
|
|
767
831
|
} = {}) {
|
|
@@ -770,12 +834,20 @@ function getWindowsHubAutostartStatus({
|
|
|
770
834
|
}
|
|
771
835
|
try {
|
|
772
836
|
execFileSync("schtasks.exe", ["/Query", "/TN", taskName], {
|
|
773
|
-
stdio: "ignore",
|
|
837
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
774
838
|
windowsHide: true,
|
|
775
839
|
});
|
|
776
840
|
return { supported: true, registered: true, taskName };
|
|
777
|
-
} catch {
|
|
778
|
-
|
|
841
|
+
} catch (error) {
|
|
842
|
+
const stderr = String(error?.stderr || "").trim();
|
|
843
|
+
const reason = classifySchtasksStderr(stderr);
|
|
844
|
+
return {
|
|
845
|
+
supported: true,
|
|
846
|
+
registered: false,
|
|
847
|
+
taskName,
|
|
848
|
+
reason,
|
|
849
|
+
stderr: stderr.slice(0, 200),
|
|
850
|
+
};
|
|
779
851
|
}
|
|
780
852
|
}
|
|
781
853
|
|
|
@@ -796,6 +868,10 @@ function ensureWindowsHubAutostart({
|
|
|
796
868
|
}
|
|
797
869
|
|
|
798
870
|
const command = buildWindowsHubAutostartCommand({ nodePath, pluginRoot });
|
|
871
|
+
|
|
872
|
+
// #161 P3: /TR 262자 제한 사전 검증을 공용 함수로 위임해 테스트/실행 로직 일관성 보장.
|
|
873
|
+
validateSchtasksTrLength(command);
|
|
874
|
+
|
|
799
875
|
const args = [
|
|
800
876
|
"/Create",
|
|
801
877
|
"/TN",
|
|
@@ -809,10 +885,23 @@ function ensureWindowsHubAutostart({
|
|
|
809
885
|
];
|
|
810
886
|
if (force) args.push("/F");
|
|
811
887
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
888
|
+
try {
|
|
889
|
+
execFileSync("schtasks.exe", args, {
|
|
890
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
891
|
+
windowsHide: true,
|
|
892
|
+
});
|
|
893
|
+
} catch (error) {
|
|
894
|
+
// #161 P3: stderr 를 error.message 에 노출해 호출자가 원인을 볼 수 있게 한다.
|
|
895
|
+
const stderr = String(error?.stderr || "").trim();
|
|
896
|
+
if (stderr) {
|
|
897
|
+
const wrapped = new Error(
|
|
898
|
+
`schtasks /Create 실패: ${stderr.slice(0, 200)}`,
|
|
899
|
+
);
|
|
900
|
+
wrapped.cause = error;
|
|
901
|
+
throw wrapped;
|
|
902
|
+
}
|
|
903
|
+
throw error;
|
|
904
|
+
}
|
|
816
905
|
|
|
817
906
|
return {
|
|
818
907
|
supported: true,
|
|
@@ -1058,6 +1147,7 @@ export {
|
|
|
1058
1147
|
BREADCRUMB_PATH,
|
|
1059
1148
|
buildWindowsHubAutostartCommand,
|
|
1060
1149
|
CLAUDE_DIR,
|
|
1150
|
+
classifySchtasksStderr,
|
|
1061
1151
|
cleanupStaleSkills,
|
|
1062
1152
|
DEPRECATED_SKILLS,
|
|
1063
1153
|
detectDevMode,
|
|
@@ -1070,17 +1160,21 @@ export {
|
|
|
1070
1160
|
getVersion,
|
|
1071
1161
|
getWindowsHubAutostartStatus,
|
|
1072
1162
|
hasProfileSection,
|
|
1163
|
+
isSetupUserStateFile,
|
|
1073
1164
|
LEGACY_CODEX_MODELS,
|
|
1074
1165
|
PLUGIN_ROOT,
|
|
1075
1166
|
REQUIRED_CODEX_PROFILES,
|
|
1076
1167
|
REQUIRED_TOP_LEVEL_SETTINGS,
|
|
1077
1168
|
readMarker,
|
|
1078
1169
|
replaceProfileSection,
|
|
1170
|
+
SCHTASKS_TR_MAX_LENGTH,
|
|
1079
1171
|
SETUP_MARKER_PATH,
|
|
1172
|
+
SETUP_USER_STATE_FILES,
|
|
1080
1173
|
SKILL_ALIASES,
|
|
1081
1174
|
SYNC_MAP,
|
|
1082
1175
|
scanHudFiles,
|
|
1083
1176
|
syncAliasedSkillDir,
|
|
1177
|
+
validateSchtasksTrLength,
|
|
1084
1178
|
WINDOWS_HUB_AUTOSTART_TASK,
|
|
1085
1179
|
writeMarker,
|
|
1086
1180
|
};
|
|
@@ -1291,7 +1385,8 @@ export async function runDeferred(stdinData) {
|
|
|
1291
1385
|
}
|
|
1292
1386
|
|
|
1293
1387
|
// ── 스킬 동기화 ──
|
|
1294
|
-
// SKILL.md + 하위 디렉토리(references/ 등)를 재귀적으로
|
|
1388
|
+
// SKILL.md + 하위 디렉토리(references/ 등)를 재귀적으로 동기화.
|
|
1389
|
+
// hosts.json 같은 user-state 파일은 설치/동기화 대상이 아니다.
|
|
1295
1390
|
|
|
1296
1391
|
const skillsSrc = join(PLUGIN_ROOT, "skills");
|
|
1297
1392
|
const skillsDst = join(CLAUDE_DIR, "skills");
|
|
@@ -1303,6 +1398,7 @@ export async function runDeferred(stdinData) {
|
|
|
1303
1398
|
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
1304
1399
|
const srcPath = join(srcDir, entry.name);
|
|
1305
1400
|
const dstPath = join(dstDir, entry.name);
|
|
1401
|
+
if (isSetupUserStateFile(entry.name)) continue;
|
|
1306
1402
|
|
|
1307
1403
|
if (entry.isDirectory()) {
|
|
1308
1404
|
count += syncSkillDir(srcPath, dstPath);
|
|
@@ -82,30 +82,89 @@ async function fileExists(filePath) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
async function writeTextAtomic(filePath, payload) {
|
|
85
|
+
// #164 MEDIUM 1: rename fallback 비원자성 개선.
|
|
86
|
+
// 기존: rename 실패 시 원본을 먼저 rm → rename(tmp, dest) 이므로 2차 실패/프로세스 중단 시 원본 유실.
|
|
87
|
+
// 개선: 원본을 backup 경로로 먼저 옮기고 (atomic rename), tmp → dest 성공 후에만 backup 삭제.
|
|
88
|
+
// tmp → dest 실패 시 backup 을 다시 dest 로 복원해 원자성 보장.
|
|
89
|
+
// backup 복원 자체가 실패하면 backup 을 **절대 삭제하지 않는다** (수동 복구용 보존).
|
|
85
90
|
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
91
|
+
const backupPath = `${filePath}.bak-${process.pid}-${Date.now()}`;
|
|
92
|
+
let hasBackup = false;
|
|
86
93
|
|
|
87
94
|
try {
|
|
88
95
|
await writeFile(tmpPath, payload, "utf8");
|
|
89
96
|
|
|
97
|
+
// 1) 원본이 있으면 backup 으로 rename (원본 유실 위험 제거)
|
|
98
|
+
try {
|
|
99
|
+
await rename(filePath, backupPath);
|
|
100
|
+
hasBackup = true;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error?.code !== "ENOENT") throw error;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2) tmp → dest
|
|
90
106
|
try {
|
|
91
107
|
await rename(tmpPath, filePath);
|
|
92
108
|
} catch (error) {
|
|
109
|
+
// Windows 에서 dest 에 stale lock 이 남아있으면 EEXIST/EPERM/EACCES 가 여전히 발생 가능.
|
|
110
|
+
// 이 경우 dest 를 제거한 뒤 재시도. backup 은 아직 살아있으므로 복원 가능.
|
|
93
111
|
if (
|
|
94
|
-
error?.code
|
|
95
|
-
error?.code
|
|
96
|
-
error?.code
|
|
112
|
+
error?.code === "EEXIST" ||
|
|
113
|
+
error?.code === "EPERM" ||
|
|
114
|
+
error?.code === "EACCES"
|
|
97
115
|
) {
|
|
116
|
+
await rm(filePath, { force: true }).catch(() => {});
|
|
117
|
+
await rename(tmpPath, filePath);
|
|
118
|
+
} else {
|
|
98
119
|
throw error;
|
|
99
120
|
}
|
|
121
|
+
}
|
|
100
122
|
|
|
101
|
-
|
|
102
|
-
|
|
123
|
+
// 3) 성공 — backup 정리
|
|
124
|
+
if (hasBackup) {
|
|
125
|
+
await rm(backupPath, { force: true }).catch(() => {});
|
|
126
|
+
hasBackup = false;
|
|
103
127
|
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
// 실패 — backup 복원 시도. 복원 성공 시에만 hasBackup=false 로 내려 cleanup 경로 진입 허용.
|
|
130
|
+
// 복원 실패 시에는 hasBackup=true 유지 → finally 에서도 backup 을 **삭제하지 않아** 수동 복구 가능.
|
|
131
|
+
if (hasBackup) {
|
|
132
|
+
try {
|
|
133
|
+
await rename(backupPath, filePath);
|
|
134
|
+
hasBackup = false;
|
|
135
|
+
} catch (rollbackError) {
|
|
136
|
+
// eslint-disable-next-line no-console — 사용자가 backup 존재를 인지해야 복구 가능
|
|
137
|
+
console.warn(
|
|
138
|
+
`[sync-hub-mcp-settings] atomic write rollback failed for ${filePath}: ${rollbackError?.message || rollbackError}. ` +
|
|
139
|
+
`Original content preserved at: ${backupPath}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw error;
|
|
104
144
|
} finally {
|
|
145
|
+
// tmp 는 항상 정리. backup 은 성공 경로/복원 경로에서만 명시적으로 rm 한다
|
|
146
|
+
// (rollback 실패 시 hasBackup=true 상태로 남음 → 이 블록에서 절대 삭제하지 않음)
|
|
105
147
|
await rm(tmpPath, { force: true }).catch(() => {});
|
|
106
148
|
}
|
|
107
149
|
}
|
|
108
150
|
|
|
151
|
+
// #164 MEDIUM 2: TOML write 후 유효성 검증.
|
|
152
|
+
// write 직전 nextRaw 가 최소 구조 (섹션 헤더 + url= 키) 를 만족하는지 확인해
|
|
153
|
+
// 깨진 TOML 을 filesystem 에 반영하지 않는다.
|
|
154
|
+
function validateCodexTomlPayload(raw, sectionName) {
|
|
155
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
156
|
+
return { ok: false, reason: "empty payload" };
|
|
157
|
+
}
|
|
158
|
+
const section = findMcpServerSection(raw, sectionName);
|
|
159
|
+
if (!section) {
|
|
160
|
+
return { ok: false, reason: "missing section header" };
|
|
161
|
+
}
|
|
162
|
+
if (!/^\s*url\s*=\s*.+$/m.test(section.body)) {
|
|
163
|
+
return { ok: false, reason: "missing url key" };
|
|
164
|
+
}
|
|
165
|
+
return { ok: true };
|
|
166
|
+
}
|
|
167
|
+
|
|
109
168
|
async function writeJsonAtomic(filePath, value) {
|
|
110
169
|
const payload = `${JSON.stringify(value, null, 2)}\n`;
|
|
111
170
|
await writeTextAtomic(filePath, payload);
|
|
@@ -261,6 +320,16 @@ async function syncCodexConfigFile({ filePath, hubUrl, dryRun, logger }) {
|
|
|
261
320
|
);
|
|
262
321
|
|
|
263
322
|
if (!dryRun) {
|
|
323
|
+
const validation = validateCodexTomlPayload(nextRaw, TFX_HUB_SECTION);
|
|
324
|
+
if (!validation.ok) {
|
|
325
|
+
const reason = `invalid toml payload: ${validation.reason}`;
|
|
326
|
+
log(
|
|
327
|
+
logger,
|
|
328
|
+
"error",
|
|
329
|
+
`[codex-mcp-sync] error: ${filePath} (${reason})`,
|
|
330
|
+
);
|
|
331
|
+
return { kind: "error", path: filePath, reason };
|
|
332
|
+
}
|
|
264
333
|
try {
|
|
265
334
|
await writeTextAtomic(filePath, nextRaw);
|
|
266
335
|
} catch (error) {
|
|
@@ -311,6 +380,12 @@ async function syncCodexConfigFile({ filePath, hubUrl, dryRun, logger }) {
|
|
|
311
380
|
);
|
|
312
381
|
|
|
313
382
|
if (!dryRun) {
|
|
383
|
+
const validation = validateCodexTomlPayload(nextRaw, TFX_HUB_SECTION);
|
|
384
|
+
if (!validation.ok) {
|
|
385
|
+
const reason = `invalid toml payload: ${validation.reason}`;
|
|
386
|
+
log(logger, "error", `[codex-mcp-sync] error: ${filePath} (${reason})`);
|
|
387
|
+
return { kind: "error", path: filePath, reason };
|
|
388
|
+
}
|
|
314
389
|
try {
|
|
315
390
|
await writeTextAtomic(filePath, nextRaw);
|
|
316
391
|
} catch (error) {
|
package/scripts/tfx-route.sh
CHANGED
|
@@ -911,38 +911,45 @@ route_agent() {
|
|
|
911
911
|
case "$agent" in
|
|
912
912
|
# ─── 구현 레인 ───
|
|
913
913
|
executor|codex)
|
|
914
|
-
CLI_ARGS="exec --profile
|
|
915
|
-
CLI_EFFORT="
|
|
914
|
+
CLI_ARGS="exec --profile gpt55_high ${codex_base}"
|
|
915
|
+
CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
916
916
|
build-fixer)
|
|
917
|
-
|
|
918
|
-
|
|
917
|
+
# 빌드 수정 — npm/biome/test-lock 등 도메인 지식 필요. base capability 우위로 gpt55_low (fast tier).
|
|
918
|
+
CLI_ARGS="exec --profile gpt55_low ${codex_base}"
|
|
919
|
+
CLI_EFFORT="gpt55_low"; DEFAULT_TIMEOUT=540; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
920
|
+
cleanup|deslop)
|
|
921
|
+
# 슬롭/정리 — 패턴 매칭 위주. 가성비 mini (gpt-5.4-mini, fast tier).
|
|
922
|
+
CLI_ARGS="exec --profile mini54_med ${codex_base}"
|
|
923
|
+
CLI_EFFORT="mini54_med"; DEFAULT_TIMEOUT=540; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
919
924
|
debugger)
|
|
920
|
-
|
|
921
|
-
|
|
925
|
+
# 디버깅 — 깊은 코드 추적 필요, gpt-5.5 xhigh
|
|
926
|
+
CLI_ARGS="exec --profile gpt55_xhigh ${codex_base}"
|
|
927
|
+
CLI_EFFORT="gpt55_xhigh"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
922
928
|
|
|
923
|
-
# ─── 설계/분석 레인 (5.4
|
|
929
|
+
# ─── 설계/분석 레인 (gpt-5.5 xhigh — 5.4 폐기, 5.5 격상) ───
|
|
924
930
|
deep-executor|architect|critic)
|
|
925
|
-
CLI_ARGS="exec --profile
|
|
926
|
-
CLI_EFFORT="
|
|
931
|
+
CLI_ARGS="exec --profile gpt55_xhigh ${codex_base}"
|
|
932
|
+
CLI_EFFORT="gpt55_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
|
|
927
933
|
planner|analyst)
|
|
928
|
-
CLI_ARGS="exec --profile
|
|
929
|
-
CLI_EFFORT="
|
|
934
|
+
CLI_ARGS="exec --profile gpt55_xhigh ${codex_base}"
|
|
935
|
+
CLI_EFFORT="gpt55_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="fg"; OPUS_OVERSIGHT="true" ;;
|
|
930
936
|
|
|
931
|
-
# ─── 리뷰 레인 (5.
|
|
937
|
+
# ─── 리뷰 레인 (gpt-5.5 — 코드 리뷰도 5.5가 강함) ───
|
|
932
938
|
code-reviewer|quality-reviewer)
|
|
933
|
-
CLI_ARGS="exec --profile
|
|
934
|
-
CLI_EFFORT="
|
|
939
|
+
CLI_ARGS="exec --profile gpt55_high ${codex_base} review"
|
|
940
|
+
CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
935
941
|
security-reviewer)
|
|
936
|
-
|
|
937
|
-
|
|
942
|
+
# 보안 = 깊은 사고 → xhigh
|
|
943
|
+
CLI_ARGS="exec --profile gpt55_xhigh ${codex_base} review"
|
|
944
|
+
CLI_EFFORT="gpt55_xhigh"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
|
|
938
945
|
|
|
939
946
|
# ─── 리서치 레인 ───
|
|
940
947
|
scientist|document-specialist)
|
|
941
|
-
CLI_ARGS="exec --profile
|
|
942
|
-
CLI_EFFORT="
|
|
948
|
+
CLI_ARGS="exec --profile gpt55_high ${codex_base}"
|
|
949
|
+
CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1440; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
943
950
|
scientist-deep)
|
|
944
|
-
CLI_ARGS="exec --profile
|
|
945
|
-
CLI_EFFORT="
|
|
951
|
+
CLI_ARGS="exec --profile gpt55_xhigh ${codex_base}"
|
|
952
|
+
CLI_EFFORT="gpt55_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
946
953
|
|
|
947
954
|
# ─── UI/문서 레인 ───
|
|
948
955
|
designer|gemini)
|
|
@@ -958,14 +965,14 @@ route_agent() {
|
|
|
958
965
|
|
|
959
966
|
# ─── 검증/테스트 ───
|
|
960
967
|
verifier)
|
|
961
|
-
CLI_ARGS="exec --profile
|
|
962
|
-
CLI_EFFORT="
|
|
968
|
+
CLI_ARGS="exec --profile gpt55_high ${codex_base} review"
|
|
969
|
+
CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
963
970
|
test-engineer)
|
|
964
|
-
CLI_ARGS="exec --profile
|
|
965
|
-
CLI_EFFORT="
|
|
971
|
+
CLI_ARGS="exec --profile gpt55_high ${codex_base}"
|
|
972
|
+
CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1200; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
966
973
|
qa-tester)
|
|
967
|
-
CLI_ARGS="exec --profile
|
|
968
|
-
CLI_EFFORT="
|
|
974
|
+
CLI_ARGS="exec --profile gpt55_high ${codex_base} review"
|
|
975
|
+
CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1200; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
969
976
|
|
|
970
977
|
# ─── 경량 ───
|
|
971
978
|
spark)
|
|
@@ -975,8 +982,8 @@ route_agent() {
|
|
|
975
982
|
*)
|
|
976
983
|
case "$CLI_TYPE" in
|
|
977
984
|
codex)
|
|
978
|
-
CLI_ARGS="exec --profile
|
|
979
|
-
CLI_EFFORT="
|
|
985
|
+
CLI_ARGS="exec --profile gpt55_high ${codex_base}"
|
|
986
|
+
CLI_EFFORT="gpt55_high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
980
987
|
gemini)
|
|
981
988
|
CLI_ARGS="-m $(resolve_gemini_profile pro31) -y --prompt"
|
|
982
989
|
CLI_EFFORT="pro31"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
@@ -1143,9 +1150,9 @@ apply_plan_guard() {
|
|
|
1143
1150
|
if [[ "$CLI_EFFORT" == spark53_* ]]; then
|
|
1144
1151
|
local codex_base
|
|
1145
1152
|
codex_base="$(build_codex_base)"
|
|
1146
|
-
CLI_ARGS="exec --profile
|
|
1147
|
-
CLI_EFFORT="
|
|
1148
|
-
echo "[tfx-route] TFX_CODEX_PLAN=$TFX_CODEX_PLAN: spark →
|
|
1153
|
+
CLI_ARGS="exec --profile gpt55_high ${codex_base}"
|
|
1154
|
+
CLI_EFFORT="gpt55_high"
|
|
1155
|
+
echo "[tfx-route] TFX_CODEX_PLAN=$TFX_CODEX_PLAN: spark → gpt55_high로 다운그레이드 (Pro 전용)" >&2
|
|
1149
1156
|
fi
|
|
1150
1157
|
}
|
|
1151
1158
|
|
|
@@ -1168,29 +1175,29 @@ apply_no_claude_native_mode() {
|
|
|
1168
1175
|
|
|
1169
1176
|
case "$AGENT_TYPE" in
|
|
1170
1177
|
explore)
|
|
1171
|
-
CLI_ARGS="exec --profile
|
|
1172
|
-
CLI_EFFORT="
|
|
1178
|
+
CLI_ARGS="exec --profile gpt55_low ${codex_base}"
|
|
1179
|
+
CLI_EFFORT="gpt55_low"
|
|
1173
1180
|
DEFAULT_TIMEOUT=600
|
|
1174
1181
|
RUN_MODE="fg"
|
|
1175
1182
|
OPUS_OVERSIGHT="false"
|
|
1176
1183
|
;;
|
|
1177
1184
|
verifier)
|
|
1178
|
-
CLI_ARGS="exec --profile
|
|
1179
|
-
CLI_EFFORT="
|
|
1185
|
+
CLI_ARGS="exec --profile gpt55_high ${codex_base} review"
|
|
1186
|
+
CLI_EFFORT="gpt55_high"
|
|
1180
1187
|
DEFAULT_TIMEOUT=1200
|
|
1181
1188
|
RUN_MODE="fg"
|
|
1182
1189
|
OPUS_OVERSIGHT="false"
|
|
1183
1190
|
;;
|
|
1184
1191
|
test-engineer)
|
|
1185
|
-
CLI_ARGS="exec --profile
|
|
1186
|
-
CLI_EFFORT="
|
|
1192
|
+
CLI_ARGS="exec --profile gpt55_high ${codex_base}"
|
|
1193
|
+
CLI_EFFORT="gpt55_high"
|
|
1187
1194
|
DEFAULT_TIMEOUT=1200
|
|
1188
1195
|
RUN_MODE="bg"
|
|
1189
1196
|
OPUS_OVERSIGHT="false"
|
|
1190
1197
|
;;
|
|
1191
1198
|
qa-tester)
|
|
1192
|
-
CLI_ARGS="exec --profile
|
|
1193
|
-
CLI_EFFORT="
|
|
1199
|
+
CLI_ARGS="exec --profile gpt55_high ${codex_base} review"
|
|
1200
|
+
CLI_EFFORT="gpt55_high"
|
|
1194
1201
|
DEFAULT_TIMEOUT=1200
|
|
1195
1202
|
RUN_MODE="bg"
|
|
1196
1203
|
OPUS_OVERSIGHT="false"
|
|
@@ -41,10 +41,17 @@ argument-hint: "[setup|spawn|list|attach|send|resume|kill|probe] ..."
|
|
|
41
41
|
- `setup --probe-all`
|
|
42
42
|
- `setup --diagnose`
|
|
43
43
|
|
|
44
|
+
`hosts.json` 은 user-state 경로 한 곳만 읽고 쓴다.
|
|
45
|
+
- macOS/Linux: `~/.config/triflux/hosts.json`
|
|
46
|
+
- Windows: `%APPDATA%\triflux\hosts.json`
|
|
47
|
+
|
|
48
|
+
기존 `references/hosts.json` 및 source/packages/global 3곳 fan-out 단계는 더 이상 사용하지 않는다.
|
|
49
|
+
첫 실행 시 legacy `references/hosts.json` 이 발견되면 lazy auto-migration으로 user-state 경로에 자동 이동된다.
|
|
50
|
+
|
|
44
51
|
### `tfx-remote spawn`
|
|
45
52
|
|
|
46
53
|
기존 `tfx-remote-spawn` 플로우를 사용하되 아래 preflight를 먼저 수행한다.
|
|
47
|
-
1. `hosts.json` 존재 확인
|
|
54
|
+
1. user-state `hosts.json` 존재 확인
|
|
48
55
|
2. 호스트명/alias 해석
|
|
49
56
|
3. probe TTL 확인
|
|
50
57
|
4. SSH 실패 시 `setup diagnose` 또는 `setup edit` 복귀 경로 제시
|
|
@@ -70,6 +77,7 @@ preflight 실패 시 중단만 하지 말고 아래 중 하나로 복귀시킨
|
|
|
70
77
|
## hosts.json contract
|
|
71
78
|
|
|
72
79
|
신규 코드는 가능하면 `hub/lib/hosts-compat.mjs`를 기준으로 해석한다.
|
|
80
|
+
저장 위치는 macOS/Linux `~/.config/triflux/hosts.json`, Windows `%APPDATA%\triflux\hosts.json` 이다.
|
|
73
81
|
- v1 legacy 필드 유지: `os`, `ssh_user`, `tailscale.ip`, `tailscale.dns`, `capabilities`
|
|
74
82
|
- v2 additive 필드 허용: `ssh.user`, `capabilities_v2`, `last_probe`
|
|
75
83
|
|
|
@@ -341,6 +341,17 @@ options:
|
|
|
341
341
|
|
|
342
342
|
저장 후 결과 보고.
|
|
343
343
|
|
|
344
|
+
**2-7-b. 소스 트리 동기화 (fan-out)**
|
|
345
|
+
|
|
346
|
+
`references/hosts.json` 은 skill reference 폴더에 위치하므로, 글로벌 (`~/.claude/skills/...`) 만 수정하면 triflux 소스 트리와 drift 가 발생한다. 수정 직후 아래 두 경로도 동일 내용으로 덮어쓴다 — 파일이 존재할 때만, 각 위치별로 `hosts.json.bak.<timestamp>` 백업 포함:
|
|
347
|
+
|
|
348
|
+
- `<triflux repo>/skills/tfx-remote-spawn/references/hosts.json`
|
|
349
|
+
- `<triflux repo>/packages/triflux/skills/tfx-remote-spawn/references/hosts.json`
|
|
350
|
+
|
|
351
|
+
triflux 소스 트리가 감지되지 않으면 이 단계를 건너뛴다. Edit 모드에서도 동일한 fan-out 을 적용한다.
|
|
352
|
+
|
|
353
|
+
> 이것은 임시 패치다. 근본 해결은 hosts.json 위치를 user-state 경로로 옮기는 것 (issue #178) 또는 `tfx setup` 의 user-state 파일 skip 로직 (issue #179).
|
|
354
|
+
|
|
344
355
|
**2-8. MCP 고아 프로세스 정리 훅 배포 (Windows 원격 호스트)**
|
|
345
356
|
|
|
346
357
|
원격 호스트가 Windows인 경우, MCP 고아 프로세스 정리 훅을 자동 배포한다.
|
|
@@ -436,6 +447,8 @@ options:
|
|
|
436
447
|
|
|
437
448
|
선택된 항목에 대해 순차 AskUserQuestion으로 새 값 입력받아 Edit 도구로 hosts.json 수정.
|
|
438
449
|
|
|
450
|
+
수정 후 **2-7-b 소스 트리 동기화 (fan-out)** 단계를 동일하게 적용한다.
|
|
451
|
+
|
|
439
452
|
#### 전체 프로브 (Probe All)
|
|
440
453
|
|
|
441
454
|
등록된 모든 호스트를 순회하며 프로브:
|
|
@@ -12,6 +12,15 @@ argument-hint: "[spawn|list|attach|send|resume|kill|probe] ..."
|
|
|
12
12
|
|
|
13
13
|
이 스킬은 Phase 4b thin alias다. 새 표면은 `tfx-remote` 명령군이다.
|
|
14
14
|
|
|
15
|
+
## hosts.json location
|
|
16
|
+
|
|
17
|
+
호스트 별칭/설정은 canonical `tfx-remote` 와 동일하게 user-state 경로 한 곳만 사용한다.
|
|
18
|
+
- macOS/Linux: `~/.config/triflux/hosts.json`
|
|
19
|
+
- Windows: `%APPDATA%\triflux\hosts.json`
|
|
20
|
+
|
|
21
|
+
기존 `references/hosts.json` 및 source/packages/global fan-out는 더 이상 사용하지 않는다.
|
|
22
|
+
첫 실행 시 legacy `references/hosts.json` 이 발견되면 lazy auto-migration으로 user-state 경로에 자동 이동된다.
|
|
23
|
+
|
|
15
24
|
## Deprecation logging (alias 호출 즉시 실행 필수)
|
|
16
25
|
|
|
17
26
|
canonical 위임 **이전** 에 아래 bash 블록을 한 번 실행한다. Phase 5 (v11) 물리 삭제 게이트는 `.omc/state/alias-usage.log` 의 7일 zero-usage 검증에 의존.
|
|
@@ -1,16 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hosts": {
|
|
3
|
-
"ultra4": {
|
|
4
|
-
"description": "Windows 데스크탑 (SSAFY)",
|
|
5
|
-
"aliases": ["울트라", "데스크탑"],
|
|
6
|
-
"default_dir": "~/Desktop/Projects"
|
|
7
|
-
},
|
|
8
3
|
"m2": {
|
|
9
|
-
"description": "MacBook
|
|
4
|
+
"description": "MacBook Air (tellang M2)",
|
|
10
5
|
"aliases": ["맥북", "맥"],
|
|
11
|
-
"default_dir": "~/projects"
|
|
6
|
+
"default_dir": "~/projects",
|
|
7
|
+
"os": "darwin",
|
|
8
|
+
"ssh_user": "tellang",
|
|
9
|
+
"tailscale": {
|
|
10
|
+
"ip": "100.104.61.126",
|
|
11
|
+
"dns": "m2.sole-hexatonic.ts.net",
|
|
12
|
+
"ssh_mode": "ssh-over-vpn"
|
|
13
|
+
},
|
|
14
|
+
"capabilities": {
|
|
15
|
+
"ssh_active": true,
|
|
16
|
+
"claude": true,
|
|
17
|
+
"node": "v25.9.0"
|
|
18
|
+
},
|
|
19
|
+
"last_probe": "2026-04-24T19:04:48Z"
|
|
20
|
+
},
|
|
21
|
+
"fold7": {
|
|
22
|
+
"description": "Samsung Z Fold 7 (Termux)",
|
|
23
|
+
"aliases": ["폴드", "폰", "fold"],
|
|
24
|
+
"default_dir": "~",
|
|
25
|
+
"os": "android",
|
|
26
|
+
"ssh_user": "",
|
|
27
|
+
"tailscale": {
|
|
28
|
+
"ip": "100.107.139.115",
|
|
29
|
+
"dns": "fold7.sole-hexatonic.ts.net",
|
|
30
|
+
"ssh_mode": "ssh-over-vpn"
|
|
31
|
+
},
|
|
32
|
+
"capabilities": {
|
|
33
|
+
"ssh_active": false,
|
|
34
|
+
"note": "Termux sshd 미실행 — Fold7에서 'pkg install openssh && sshd -p 8022' 후 probe 재실행"
|
|
35
|
+
},
|
|
36
|
+
"last_probe": "2026-04-24T19:04:48Z"
|
|
12
37
|
}
|
|
13
38
|
},
|
|
14
|
-
"default_host": "
|
|
39
|
+
"default_host": "m2",
|
|
15
40
|
"triggers": ["원격에서", "다른 머신에서", "다른 컴퓨터에서"]
|
|
16
41
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hosts": {
|
|
3
|
+
"ultra4": {
|
|
4
|
+
"description": "Windows 데스크탑 (SSAFY)",
|
|
5
|
+
"aliases": ["울트라", "데스크탑"],
|
|
6
|
+
"default_dir": "~/Desktop/Projects"
|
|
7
|
+
},
|
|
8
|
+
"m2": {
|
|
9
|
+
"description": "MacBook Pro",
|
|
10
|
+
"aliases": ["맥북", "맥"],
|
|
11
|
+
"default_dir": "~/projects"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"default_host": "ultra4",
|
|
15
|
+
"triggers": ["원격에서", "다른 머신에서", "다른 컴퓨터에서"]
|
|
16
|
+
}
|
|
@@ -44,7 +44,7 @@ options:
|
|
|
44
44
|
Bash("triflux setup")
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
스크립트/HUD/스킬을 `~/.claude/`에 배포. 결과 표시.
|
|
47
|
+
스크립트/HUD/스킬을 `~/.claude/`에 배포. 결과 표시. `tfx setup` 은 user-state 파일(예: macOS/Linux `~/.config/triflux/hosts.json`, Windows `%APPDATA%\triflux\hosts.json`)을 덮어쓰지 않는다.
|
|
48
48
|
|
|
49
49
|
#### 단계 1.5: 훅 등록 확인
|
|
50
50
|
|
|
@@ -254,7 +254,11 @@ options:
|
|
|
254
254
|
|
|
255
255
|
#### 단계 3.8: 원격 기기 프로빙 (Swarm Multi-Machine)
|
|
256
256
|
|
|
257
|
-
`
|
|
257
|
+
user-state `hosts.json` 존재 여부 확인.
|
|
258
|
+
- macOS/Linux: `~/.config/triflux/hosts.json`
|
|
259
|
+
- Windows: `%APPDATA%\triflux\hosts.json`
|
|
260
|
+
|
|
261
|
+
기존 source-tree `references/hosts.json` 은 더 이상 사용하지 않는다. legacy 파일이 남아 있으면 첫 원격 실행 시 lazy auto-migration으로 user-state 경로에 자동 이동된다.
|
|
258
262
|
|
|
259
263
|
- 파일 없음 → AskUserQuestion:
|
|
260
264
|
```
|