@triflux/core 10.0.0 → 10.0.2
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/hooks/hook-orchestrator.mjs +48 -1
- package/hooks/keyword-rules.json +19 -41
- package/hooks/safety-guard.mjs +61 -35
- package/hub/adaptive-inject.mjs +1 -1
- package/hub/cli-adapter-base.mjs +7 -6
- package/hub/lib/ssh-command.mjs +74 -13
- package/hub/lib/ssh-retry.mjs +59 -0
- package/hub/state.mjs +27 -14
- package/package.json +11 -3
- package/scripts/lib/skill-template.mjs +91 -10
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// CLAUDE_PLUGIN_ROOT — ${PLUGIN_ROOT} 치환용
|
|
20
20
|
// HOME / USERPROFILE — ${HOME} 치환용
|
|
21
21
|
|
|
22
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
22
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
23
23
|
import { join, dirname } from "node:path";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
import { execFileSync, execFile } from "node:child_process";
|
|
@@ -210,8 +210,55 @@ function mergeOutputs(accumulated, newOutput) {
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
// ── 라우팅 가중치 기록 ────────────────────────────────────────
|
|
214
|
+
function recordRouteOutcome(slug, mode, outcome) {
|
|
215
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
216
|
+
const projDir = join(home, ".gstack", "projects", slug);
|
|
217
|
+
const weightsPath = join(projDir, "routing-weights.json");
|
|
218
|
+
|
|
219
|
+
mkdirSync(projDir, { recursive: true });
|
|
220
|
+
|
|
221
|
+
const weights = existsSync(weightsPath)
|
|
222
|
+
? JSON.parse(readFileSync(weightsPath, "utf8"))
|
|
223
|
+
: { updated_at: null, total_routes: 0, overrides: 0, weights: { mode_bias: {}, profile_bias: {}, depth_bias: {} } };
|
|
224
|
+
|
|
225
|
+
weights.total_routes++;
|
|
226
|
+
weights.updated_at = new Date().toISOString();
|
|
227
|
+
|
|
228
|
+
const bias = weights.weights.mode_bias;
|
|
229
|
+
const current = bias[mode] || 0;
|
|
230
|
+
|
|
231
|
+
if (outcome === "override") {
|
|
232
|
+
bias[mode] = Math.max(0, current - 0.1);
|
|
233
|
+
weights.overrides++;
|
|
234
|
+
} else if (outcome === "completion") {
|
|
235
|
+
bias[mode] = Math.min(1, current + 0.05);
|
|
236
|
+
} else if (outcome === "abort") {
|
|
237
|
+
bias[mode] = Math.max(0, current - 0.1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const total = Object.values(bias).reduce((s, v) => s + v, 0);
|
|
241
|
+
if (total > 0) {
|
|
242
|
+
for (const key of Object.keys(bias)) {
|
|
243
|
+
bias[key] = +(bias[key] / total).toFixed(3);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
writeFileSync(weightsPath, JSON.stringify(weights, null, 2), "utf8");
|
|
248
|
+
}
|
|
249
|
+
|
|
213
250
|
// ── 메인 ────────────────────────────────────────────────────
|
|
214
251
|
async function main() {
|
|
252
|
+
// CLI: --record-route <slug> <mode> <outcome>
|
|
253
|
+
const rrIdx = process.argv.indexOf("--record-route");
|
|
254
|
+
if (rrIdx !== -1) {
|
|
255
|
+
const slug = process.argv[rrIdx + 1];
|
|
256
|
+
const mode = process.argv[rrIdx + 2];
|
|
257
|
+
const outcome = process.argv[rrIdx + 3];
|
|
258
|
+
if (slug && mode && outcome) recordRouteOutcome(slug, mode, outcome);
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}
|
|
261
|
+
|
|
215
262
|
const stdinRaw = readStdin();
|
|
216
263
|
const registry = loadRegistry();
|
|
217
264
|
|
package/hooks/keyword-rules.json
CHANGED
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
"priority": 0,
|
|
14
14
|
"supersedes": [
|
|
15
15
|
"tfx-multi",
|
|
16
|
-
"tfx-
|
|
17
|
-
"tfx-auto-codex",
|
|
16
|
+
"tfx-unified",
|
|
18
17
|
"tfx-codex",
|
|
19
18
|
"tfx-gemini"
|
|
20
19
|
],
|
|
@@ -38,8 +37,12 @@
|
|
|
38
37
|
"mcp_route": null
|
|
39
38
|
},
|
|
40
39
|
{
|
|
41
|
-
"id": "tfx-
|
|
40
|
+
"id": "tfx-swarm",
|
|
42
41
|
"patterns": [
|
|
42
|
+
{
|
|
43
|
+
"source": "\\btfx[\\s-]?swarm\\b",
|
|
44
|
+
"flags": "i"
|
|
45
|
+
},
|
|
43
46
|
{
|
|
44
47
|
"source": "\\btfx[\\s-]?codex[\\s-]?swarm\\b",
|
|
45
48
|
"flags": "i"
|
|
@@ -47,21 +50,7 @@
|
|
|
47
50
|
{
|
|
48
51
|
"source": "\\bcodex[\\s-]?swarm\\b",
|
|
49
52
|
"flags": "i"
|
|
50
|
-
}
|
|
51
|
-
],
|
|
52
|
-
"skill": "tfx-swarm",
|
|
53
|
-
"deprecated_from": "tfx-codex-swarm",
|
|
54
|
-
"priority": 1,
|
|
55
|
-
"supersedes": [
|
|
56
|
-
"tfx-codex"
|
|
57
|
-
],
|
|
58
|
-
"exclusive": false,
|
|
59
|
-
"state": null,
|
|
60
|
-
"mcp_route": null
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
"id": "tfx-remote-spawn",
|
|
64
|
-
"patterns": [
|
|
53
|
+
},
|
|
65
54
|
{
|
|
66
55
|
"source": "\\btfx[\\s-]?remote[\\s-]?spawn\\b",
|
|
67
56
|
"flags": "i"
|
|
@@ -71,25 +60,9 @@
|
|
|
71
60
|
"flags": "i"
|
|
72
61
|
}
|
|
73
62
|
],
|
|
74
|
-
"skill": "tfx-
|
|
75
|
-
"priority": 1,
|
|
76
|
-
"supersedes": [],
|
|
77
|
-
"exclusive": false,
|
|
78
|
-
"state": null,
|
|
79
|
-
"mcp_route": null
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
"id": "tfx-auto-codex",
|
|
83
|
-
"patterns": [
|
|
84
|
-
{
|
|
85
|
-
"source": "\\btfx[\\s-]?auto[\\s-]?codex\\b",
|
|
86
|
-
"flags": "i"
|
|
87
|
-
}
|
|
88
|
-
],
|
|
89
|
-
"skill": "tfx-auto-codex",
|
|
63
|
+
"skill": "tfx-swarm",
|
|
90
64
|
"priority": 1,
|
|
91
65
|
"supersedes": [
|
|
92
|
-
"tfx-auto",
|
|
93
66
|
"tfx-codex"
|
|
94
67
|
],
|
|
95
68
|
"exclusive": false,
|
|
@@ -97,16 +70,21 @@
|
|
|
97
70
|
"mcp_route": null
|
|
98
71
|
},
|
|
99
72
|
{
|
|
100
|
-
"id": "tfx-
|
|
73
|
+
"id": "tfx-unified",
|
|
101
74
|
"patterns": [
|
|
102
|
-
{
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
75
|
+
{ "source": "\\btfx[\\s-]?auto\\b", "flags": "i" },
|
|
76
|
+
{ "source": "\\btfx[\\s-]?auto[\\s-]?codex\\b", "flags": "i" },
|
|
77
|
+
{ "source": "(?:만들어|고쳐|구현해|짜줘|수정해|바꿔)", "flags": "i" },
|
|
78
|
+
{ "source": "(?:리뷰해|검토해|봐줘|괜찮아)", "flags": "i" },
|
|
79
|
+
{ "source": "(?:테스트|검증|돌려봐|QA)", "flags": "i" },
|
|
80
|
+
{ "source": "(?:분석해|계획|설계해)", "flags": "i" },
|
|
81
|
+
{ "source": "(?:찾아봐|조사해|검색해)", "flags": "i" },
|
|
82
|
+
{ "source": "(?:정리해|슬롭|클린업)", "flags": "i" },
|
|
83
|
+
{ "source": "\\b(?:implement|build|fix|review|test|plan|analyze)\\b", "flags": "i" }
|
|
106
84
|
],
|
|
107
85
|
"skill": "tfx-auto",
|
|
108
86
|
"priority": 2,
|
|
109
|
-
"supersedes": [],
|
|
87
|
+
"supersedes": ["tfx-auto-codex"],
|
|
110
88
|
"exclusive": false,
|
|
111
89
|
"state": null,
|
|
112
90
|
"mcp_route": null
|
package/hooks/safety-guard.mjs
CHANGED
|
@@ -26,6 +26,18 @@ const BLOCK_RULES = [
|
|
|
26
26
|
{ pattern: /\bpsmux\s+kill-server\b/i, reason: "psmux kill-server 차단 — 모든 세션이 즉시 종료됩니다. node hub/team/psmux.mjs kill-swarm 사용", skipIfGit: true },
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
+
const WT_DIRECT_PATTERNS = [
|
|
30
|
+
/\bwt\.exe\b/i,
|
|
31
|
+
/\bwt\s+new-tab\b/i,
|
|
32
|
+
/\bwt\s+split-pane\b/i,
|
|
33
|
+
/\bwt\s+-w\b/i,
|
|
34
|
+
/\bStart-Process\s+wt/i,
|
|
35
|
+
/\bStart-Process\s+['"]?wt\.exe/i,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const WT_DIRECT_BLOCK_MESSAGE =
|
|
39
|
+
"[safety-guard] wt.exe 직접 호출 차단됨. → hub/team/wt-manager.mjs의 createTab() / splitPane() / applySplitLayout()을 사용하세요.";
|
|
40
|
+
|
|
29
41
|
// ── 경고 규칙 ──────────────────────────────────────────────
|
|
30
42
|
const WARN_RULES = [
|
|
31
43
|
{ pattern: /\bgit\s+push\b(?!.*--force)/i, warn: "git push 감지. 원격 저장소에 반영됩니다." },
|
|
@@ -46,6 +58,45 @@ function readStdin() {
|
|
|
46
58
|
}
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
function shouldSkipSegment(segment) {
|
|
62
|
+
return !segment || segment.startsWith("#") || /^\s*(echo|printf|grep|git\s+commit)\b/i.test(segment);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasSegmentInvocation(cmd, patterns) {
|
|
66
|
+
if (!patterns.some((pattern) => pattern.test(cmd))) return false;
|
|
67
|
+
|
|
68
|
+
const lines = cmd.split(/\n/);
|
|
69
|
+
let heredocDelimiter = null;
|
|
70
|
+
return lines.some((line) => {
|
|
71
|
+
if (heredocDelimiter !== null) {
|
|
72
|
+
if (line.trim() === heredocDelimiter) heredocDelimiter = null;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const heredocMatch = line.match(/<<['"]?(\w+)['"]?/);
|
|
77
|
+
if (heredocMatch) {
|
|
78
|
+
heredocDelimiter = heredocMatch[1];
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const segments = line.split(/\s*(?:&&|;|\|\|)\s*/);
|
|
83
|
+
return segments.some((seg) => {
|
|
84
|
+
const trimmed = seg.trim();
|
|
85
|
+
if (shouldSkipSegment(trimmed)) return false;
|
|
86
|
+
return patterns.some((pattern) => pattern.test(trimmed));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function blockCommand(message, command) {
|
|
92
|
+
process.stderr.write(
|
|
93
|
+
`${message}\n` +
|
|
94
|
+
`명령어: ${command.slice(0, 120)}${command.length > 120 ? "..." : ""}\n` +
|
|
95
|
+
"이 명령은 실행할 수 없습니다. 안전한 대안을 사용하세요."
|
|
96
|
+
);
|
|
97
|
+
process.exit(2);
|
|
98
|
+
}
|
|
99
|
+
|
|
49
100
|
function main() {
|
|
50
101
|
const raw = readStdin();
|
|
51
102
|
if (!raw.trim()) process.exit(0);
|
|
@@ -65,47 +116,22 @@ function main() {
|
|
|
65
116
|
// psmux 명령이 실제 CLI 호출인지 판별 (오탐 방지)
|
|
66
117
|
// git commit 메시지, echo, grep, cat, heredoc 안의 텍스트는 무시
|
|
67
118
|
function isPsmuxInvocation(cmd) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (heredocDelimiter !== null) {
|
|
78
|
-
if (line.trim() === heredocDelimiter) heredocDelimiter = null;
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
// heredoc 시작 감지: <<'WORD', <<"WORD", <<WORD
|
|
82
|
-
const heredocMatch = line.match(/<<['"]?(\w+)['"]?/);
|
|
83
|
-
if (heredocMatch) {
|
|
84
|
-
heredocDelimiter = heredocMatch[1];
|
|
85
|
-
// heredoc 시작 줄 자체는 실제 명령이므로 계속 검사하되
|
|
86
|
-
// 시작 줄에 psmux kill이 포함될 리 없으므로 여기서 false 반환
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
const segments = line.split(/\s*(?:&&|;|\|\|)\s*/);
|
|
90
|
-
return segments.some((seg) => {
|
|
91
|
-
const t = seg.trim();
|
|
92
|
-
if (!t || t.startsWith("#")) return false;
|
|
93
|
-
if (/^\s*(echo|printf|grep|git\s+commit)\b/i.test(t)) return false;
|
|
94
|
-
return /\bpsmux\s+kill-(session|server)\b/i.test(t);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
119
|
+
return hasSegmentInvocation(cmd, [/\bpsmux\s+kill-(session|server)\b/i]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isWtDirectInvocation(cmd) {
|
|
123
|
+
return hasSegmentInvocation(cmd, WT_DIRECT_PATTERNS);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (isWtDirectInvocation(command)) {
|
|
127
|
+
blockCommand(WT_DIRECT_BLOCK_MESSAGE, command);
|
|
97
128
|
}
|
|
98
129
|
|
|
99
130
|
// 1. BLOCK 체크 — exit 2로 차단
|
|
100
131
|
for (const rule of BLOCK_RULES) {
|
|
101
132
|
if (rule.skipIfGit && !isPsmuxInvocation(command)) continue;
|
|
102
133
|
if (rule.pattern.test(command)) {
|
|
103
|
-
|
|
104
|
-
`[triflux safety-guard] BLOCKED: ${rule.reason}\n` +
|
|
105
|
-
`명령어: ${command.slice(0, 120)}${command.length > 120 ? "..." : ""}\n` +
|
|
106
|
-
`이 명령은 실행할 수 없습니다. 안전한 대안을 사용하세요.`
|
|
107
|
-
);
|
|
108
|
-
process.exit(2);
|
|
134
|
+
blockCommand(`[triflux safety-guard] BLOCKED: ${rule.reason}`, command);
|
|
109
135
|
}
|
|
110
136
|
}
|
|
111
137
|
|
package/hub/adaptive-inject.mjs
CHANGED
package/hub/cli-adapter-base.mjs
CHANGED
|
@@ -38,15 +38,16 @@ export function gte(minMinor) {
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Codex CLI 기능별 분기 객체.
|
|
41
|
-
*
|
|
41
|
+
* 실측 기반 임계값: 0.114.0에서 exec/skip-git-repo-check/color 확인됨.
|
|
42
|
+
* --output-last-message는 0.114.0에 없음 (0.117+ 추정).
|
|
42
43
|
*/
|
|
43
44
|
export const FEATURES = {
|
|
44
|
-
/** exec 서브커맨드 사용 가능 여부 */
|
|
45
|
-
get execSubcommand() { return gte(
|
|
46
|
-
/** --output-last-message 플래그 지원 여부 */
|
|
45
|
+
/** exec 서브커맨드 사용 가능 여부 (0.110+ 이전부터 존재) */
|
|
46
|
+
get execSubcommand() { return gte(110); },
|
|
47
|
+
/** --output-last-message 플래그 지원 여부 (0.117+) */
|
|
47
48
|
get outputLastMessage() { return gte(117); },
|
|
48
|
-
/** --color
|
|
49
|
-
get colorNever() { return gte(
|
|
49
|
+
/** --color <COLOR> 플래그 지원 여부 (exec와 동시 도입) */
|
|
50
|
+
get colorNever() { return gte(110); },
|
|
50
51
|
/** 플러그인 시스템 지원 여부 (향후 확장용) */
|
|
51
52
|
get pluginSystem() { return gte(120); },
|
|
52
53
|
};
|
package/hub/lib/ssh-command.mjs
CHANGED
|
@@ -8,6 +8,22 @@ import { join } from "node:path";
|
|
|
8
8
|
/** hosts.json 캐시 (프로세스 수명 동안 유지) */
|
|
9
9
|
let hostsCache = null;
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* hosts.json을 로드하여 캐시한다.
|
|
13
|
+
* @param {string} [repoRoot] — 프로젝트 루트 경로
|
|
14
|
+
*/
|
|
15
|
+
function loadHostsCache(repoRoot) {
|
|
16
|
+
if (hostsCache) return;
|
|
17
|
+
try {
|
|
18
|
+
const hostsPath = repoRoot
|
|
19
|
+
? join(repoRoot, "references", "hosts.json")
|
|
20
|
+
: join(process.cwd(), "references", "hosts.json");
|
|
21
|
+
hostsCache = JSON.parse(readFileSync(hostsPath, "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
hostsCache = { hosts: {} };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
11
27
|
/**
|
|
12
28
|
* hosts.json에서 호스트 OS를 조회한다.
|
|
13
29
|
* @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
|
|
@@ -15,16 +31,7 @@ let hostsCache = null;
|
|
|
15
31
|
* @returns {"windows"|"posix"} 호스트 OS 유형
|
|
16
32
|
*/
|
|
17
33
|
export function detectHostOs(hostAlias, repoRoot) {
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
const hostsPath = repoRoot
|
|
21
|
-
? join(repoRoot, "references", "hosts.json")
|
|
22
|
-
: join(process.cwd(), "references", "hosts.json");
|
|
23
|
-
hostsCache = JSON.parse(readFileSync(hostsPath, "utf8"));
|
|
24
|
-
} catch {
|
|
25
|
-
hostsCache = { hosts: {} };
|
|
26
|
-
}
|
|
27
|
-
}
|
|
34
|
+
loadHostsCache(repoRoot);
|
|
28
35
|
|
|
29
36
|
const hostCfg = hostsCache.hosts?.[hostAlias];
|
|
30
37
|
if (hostCfg?.os === "windows") return "windows";
|
|
@@ -88,12 +95,14 @@ export function nullDevice(os) {
|
|
|
88
95
|
* @param {string[]} remoteCmd — 원격에서 실행할 명령 + 인자
|
|
89
96
|
* @param {object} [opts]
|
|
90
97
|
* @param {number} [opts.connectTimeout=5] — SSH 연결 타임아웃(초)
|
|
98
|
+
* @param {boolean} [opts.keepalive=true] — ServerAliveInterval/CountMax 활성화
|
|
91
99
|
* @param {"windows"|"posix"} [opts.os] — 호스트 OS (미지정 시 자동 감지)
|
|
92
100
|
* @param {string} [opts.repoRoot] — hosts.json 탐색 경로
|
|
93
101
|
* @returns {string[]} execFileSync('ssh', returnValue) 형태로 사용
|
|
94
102
|
*/
|
|
95
103
|
export function buildSshArgs(host, remoteCmd, opts = {}) {
|
|
96
104
|
const connectTimeout = opts.connectTimeout ?? 5;
|
|
105
|
+
const keepalive = opts.keepalive !== false;
|
|
97
106
|
const hostAlias = host.includes("@") ? host.split("@").pop() : host;
|
|
98
107
|
const os = opts.os ?? detectHostOs(hostAlias, opts.repoRoot);
|
|
99
108
|
|
|
@@ -102,12 +111,17 @@ export function buildSshArgs(host, remoteCmd, opts = {}) {
|
|
|
102
111
|
const quotedArgs = args.map((a) => shellQuoteForHost(a, os));
|
|
103
112
|
const remoteCmdStr = [cmd, ...quotedArgs].join(" ");
|
|
104
113
|
|
|
105
|
-
|
|
114
|
+
const sshArgs = [
|
|
106
115
|
"-o", `ConnectTimeout=${connectTimeout}`,
|
|
107
116
|
"-o", "BatchMode=yes",
|
|
108
|
-
host,
|
|
109
|
-
remoteCmdStr,
|
|
110
117
|
];
|
|
118
|
+
|
|
119
|
+
if (keepalive) {
|
|
120
|
+
sshArgs.push("-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=3");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
sshArgs.push(host, remoteCmdStr);
|
|
124
|
+
return sshArgs;
|
|
111
125
|
}
|
|
112
126
|
|
|
113
127
|
/**
|
|
@@ -144,6 +158,53 @@ export function validateCommandForOs(command, os) {
|
|
|
144
158
|
return { safe: violations.length === 0, violations };
|
|
145
159
|
}
|
|
146
160
|
|
|
161
|
+
/**
|
|
162
|
+
* hosts.json에서 호스트 전체 설정을 조회한다.
|
|
163
|
+
* @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
|
|
164
|
+
* @param {string} [repoRoot]
|
|
165
|
+
* @returns {object|null}
|
|
166
|
+
*/
|
|
167
|
+
export function getHostConfig(hostAlias, repoRoot) {
|
|
168
|
+
loadHostsCache(repoRoot);
|
|
169
|
+
return hostsCache.hosts?.[hostAlias] ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 별칭(alias)으로 호스트 키를 찾는다.
|
|
174
|
+
* @param {string} alias — 호스트 키 또는 aliases 배열 내 값
|
|
175
|
+
* @param {string} [repoRoot]
|
|
176
|
+
* @returns {string|null} 호스트 키 (e.g. "ultra4") 또는 null
|
|
177
|
+
*/
|
|
178
|
+
export function resolveHostAlias(alias, repoRoot) {
|
|
179
|
+
loadHostsCache(repoRoot);
|
|
180
|
+
|
|
181
|
+
if (hostsCache.hosts?.[alias]) return alias;
|
|
182
|
+
|
|
183
|
+
for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
|
|
184
|
+
if (cfg.aliases?.includes(alias)) return name;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 특정 capability를 보유한 호스트 목록을 반환한다.
|
|
192
|
+
* @param {string} capability — e.g. "codex", "claude", "gpu"
|
|
193
|
+
* @param {string} [repoRoot]
|
|
194
|
+
* @returns {Array<{ name: string, config: object }>}
|
|
195
|
+
*/
|
|
196
|
+
export function selectHostForCapability(capability, repoRoot) {
|
|
197
|
+
loadHostsCache(repoRoot);
|
|
198
|
+
|
|
199
|
+
const matches = [];
|
|
200
|
+
for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
|
|
201
|
+
if (cfg.capabilities?.includes(capability)) {
|
|
202
|
+
matches.push({ name, config: cfg });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return matches;
|
|
206
|
+
}
|
|
207
|
+
|
|
147
208
|
/** hosts.json 캐시 초기화 (테스트용) */
|
|
148
209
|
export function resetHostsCache() {
|
|
149
210
|
hostsCache = null;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// hub/lib/ssh-retry.mjs — SSH command retry with exponential backoff
|
|
2
|
+
// transient SSH 에러(connection reset, broken pipe 등)를 감지하여 자동 재시도한다.
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
7
|
+
const DEFAULT_BASE_DELAY_MS = 1000;
|
|
8
|
+
|
|
9
|
+
/** transient SSH 에러 패턴 */
|
|
10
|
+
const TRANSIENT_RE =
|
|
11
|
+
/connection (reset|refused|timed out)|broken pipe|network is unreachable|ssh_exchange_identification|kex_exchange_identification/i;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* execFileSync('ssh', args) 를 retry 래핑한다.
|
|
15
|
+
* transient 에러만 재시도하고, 그 외 에러는 즉시 throw.
|
|
16
|
+
*
|
|
17
|
+
* @param {string[]} args — ssh 인자 배열
|
|
18
|
+
* @param {object} [opts] — execFileSync 옵션 + retry 설정
|
|
19
|
+
* @param {number} [opts.maxRetries=2]
|
|
20
|
+
* @param {number} [opts.baseDelayMs=1000]
|
|
21
|
+
* @returns {string|Buffer} ssh stdout
|
|
22
|
+
*/
|
|
23
|
+
export function execSshWithRetry(args, opts = {}) {
|
|
24
|
+
const { maxRetries = DEFAULT_MAX_RETRIES, baseDelayMs = DEFAULT_BASE_DELAY_MS, ...execOpts } = opts;
|
|
25
|
+
|
|
26
|
+
let lastError;
|
|
27
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
28
|
+
try {
|
|
29
|
+
return execFileSync("ssh", args, execOpts);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
lastError = err;
|
|
32
|
+
if (attempt < maxRetries && isTransientSshError(err)) {
|
|
33
|
+
sleepSync(baseDelayMs * 2 ** attempt);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw lastError;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* transient SSH 에러인지 판별한다.
|
|
44
|
+
* @param {Error} err
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function isTransientSshError(err) {
|
|
48
|
+
const msg = String(err.stderr || err.message || "");
|
|
49
|
+
return TRANSIENT_RE.test(msg);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 동기 sleep (Atomics.wait 기반).
|
|
54
|
+
* @param {number} ms
|
|
55
|
+
*/
|
|
56
|
+
function sleepSync(ms) {
|
|
57
|
+
if (ms <= 0) return;
|
|
58
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
59
|
+
}
|
package/hub/state.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
7
7
|
const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
8
|
-
const
|
|
8
|
+
const PID_FILE_NAME = 'hub.pid';
|
|
9
|
+
const LEGACY_STATE_FILE_NAME = 'hub-state.json';
|
|
9
10
|
const LOCK_FILE_NAME = 'hub-start.lock';
|
|
10
11
|
|
|
11
12
|
let heldLockPath = null;
|
|
@@ -17,7 +18,11 @@ function getStateDir(options = {}) {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function getStatePath(options = {}) {
|
|
20
|
-
return join(getStateDir(options),
|
|
21
|
+
return options.statePath || join(getStateDir(options), PID_FILE_NAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getLegacyStatePath(options = {}) {
|
|
25
|
+
return join(getStateDir(options), LEGACY_STATE_FILE_NAME);
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
function getLockPath(options = {}) {
|
|
@@ -59,6 +64,21 @@ function safeReplaceFile(tempPath, targetPath) {
|
|
|
59
64
|
}
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
function writeJsonFile(targetPath, payload) {
|
|
68
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
|
69
|
+
writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
70
|
+
safeReplaceFile(tempPath, targetPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readJsonFile(filePath) {
|
|
74
|
+
try {
|
|
75
|
+
if (!existsSync(filePath)) return null;
|
|
76
|
+
return parseJson(readFileSync(filePath, 'utf8'), null);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
/**
|
|
63
83
|
* 허브의 현재 상태(PID, 포트, 버전 등)를 파일에 기록합니다.
|
|
64
84
|
* 원자적(atomic) 쓰기를 위해 임시 파일을 생성한 후 교체하는 방식을 사용합니다.
|
|
@@ -73,15 +93,14 @@ function safeReplaceFile(tempPath, targetPath) {
|
|
|
73
93
|
* @param {string} [options.stateDir] - 상태 파일이 저장될 디렉토리
|
|
74
94
|
* @returns {object} 기록된 상태 데이터
|
|
75
95
|
*/
|
|
76
|
-
export function writeState({ pid, port, version, sessionId, startedAt }, options = {}) {
|
|
96
|
+
export function writeState({ pid, port, version, sessionId, startedAt, ...rest }, options = {}) {
|
|
77
97
|
const stateDir = getStateDir(options);
|
|
78
98
|
const statePath = getStatePath(options);
|
|
79
|
-
const
|
|
80
|
-
const payload = { pid, port, version, sessionId, startedAt };
|
|
99
|
+
const payload = { pid, port, version, sessionId, startedAt, ...rest };
|
|
81
100
|
|
|
82
101
|
mkdirSync(stateDir, { recursive: true });
|
|
83
|
-
|
|
84
|
-
|
|
102
|
+
writeJsonFile(statePath, payload);
|
|
103
|
+
try { unlinkSync(getLegacyStatePath(options)); } catch {}
|
|
85
104
|
return payload;
|
|
86
105
|
}
|
|
87
106
|
|
|
@@ -93,13 +112,7 @@ export function writeState({ pid, port, version, sessionId, startedAt }, options
|
|
|
93
112
|
* @returns {object|null} 읽어온 상태 데이터 또는 실패 시 null
|
|
94
113
|
*/
|
|
95
114
|
export function readState(options = {}) {
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
if (!existsSync(statePath)) return null;
|
|
99
|
-
return parseJson(readFileSync(statePath, 'utf8'), null);
|
|
100
|
-
} catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
115
|
+
return readJsonFile(getStatePath(options)) ?? readJsonFile(getLegacyStatePath(options));
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@triflux/core",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.2",
|
|
4
4
|
"description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "hub/index.mjs",
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"./hub/*": "./hub/*",
|
|
10
10
|
"./scripts/*": "./scripts/*"
|
|
11
11
|
},
|
|
12
|
-
"engines": {
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18.0.0"
|
|
14
|
+
},
|
|
13
15
|
"dependencies": {
|
|
14
16
|
"pino": "^10.3.1",
|
|
15
17
|
"pino-pretty": "^13.1.3",
|
|
@@ -21,7 +23,13 @@
|
|
|
21
23
|
"hooks",
|
|
22
24
|
"hud"
|
|
23
25
|
],
|
|
24
|
-
"keywords": [
|
|
26
|
+
"keywords": [
|
|
27
|
+
"claude-code",
|
|
28
|
+
"cli-routing",
|
|
29
|
+
"codex",
|
|
30
|
+
"gemini",
|
|
31
|
+
"triflux"
|
|
32
|
+
],
|
|
25
33
|
"author": "tellang",
|
|
26
34
|
"license": "MIT",
|
|
27
35
|
"repository": {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { basename, extname, join, relative } from "node:path";
|
|
2
|
+
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
const IF_TAG_RE = /{{\s*(#if\s+([A-Za-z0-9_.-]+)|\/if)\s*}}/g;
|
|
5
|
-
const
|
|
5
|
+
const PARTIAL_RE = /{{>\s*([A-Za-z0-9_./-]+)\s*}}/g;
|
|
6
|
+
const FILE_INCLUDE_RE = /{{#include\s+([A-Za-z0-9_./-]+)\s*}}/g;
|
|
6
7
|
const VARIABLE_RE = /{{\s*([A-Za-z0-9_.-]+)\s*}}/g;
|
|
7
8
|
const FRONTMATTER_RE = /^---\r?\n(?:([\s\S]*?)\r?\n)?---\r?\n?/;
|
|
8
9
|
|
|
@@ -152,8 +153,8 @@ function evaluateConditionals(source, context) {
|
|
|
152
153
|
return walk(0, false).output;
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
function
|
|
156
|
-
return source.replace(
|
|
156
|
+
function renderPartials(source, context, partials, options, includeStack) {
|
|
157
|
+
return source.replace(PARTIAL_RE, (_full, partialName) => {
|
|
157
158
|
const partial = partials[partialName];
|
|
158
159
|
if (partial == null) {
|
|
159
160
|
throw new Error(`Missing partial: ${partialName}`);
|
|
@@ -162,7 +163,53 @@ function renderIncludes(source, context, partials, includeStack) {
|
|
|
162
163
|
const chain = [...includeStack, partialName].join(" -> ");
|
|
163
164
|
throw new Error(`Circular partial include: ${chain}`);
|
|
164
165
|
}
|
|
165
|
-
return renderWithContext(partial, context, partials,
|
|
166
|
+
return renderWithContext(partial, context, partials, options, [
|
|
167
|
+
...includeStack,
|
|
168
|
+
partialName,
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeIncludeName(includeName) {
|
|
174
|
+
return includeName.replace(/\\/g, "/");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function resolveIncludeContent(includeName, options) {
|
|
178
|
+
const normalizedName = normalizeIncludeName(includeName);
|
|
179
|
+
if (Object.prototype.hasOwnProperty.call(options.includes, normalizedName)) {
|
|
180
|
+
return options.includes[normalizedName];
|
|
181
|
+
}
|
|
182
|
+
if (!options.includeBaseDir) {
|
|
183
|
+
throw new Error(`Missing include: ${normalizedName}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const baseDir = resolve(options.includeBaseDir);
|
|
187
|
+
const fullPath = resolve(baseDir, normalizedName);
|
|
188
|
+
const relativePath = relative(baseDir, fullPath);
|
|
189
|
+
const isInsideBaseDir =
|
|
190
|
+
relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
191
|
+
|
|
192
|
+
if (!isInsideBaseDir || !existsSync(fullPath)) {
|
|
193
|
+
throw new Error(`Missing include: ${normalizedName}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const content = readFileSync(fullPath, "utf8");
|
|
197
|
+
options.includes[normalizedName] = content;
|
|
198
|
+
return content;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function renderFileIncludes(source, context, partials, options, includeStack) {
|
|
202
|
+
return source.replace(FILE_INCLUDE_RE, (_full, includeName) => {
|
|
203
|
+
const normalizedName = normalizeIncludeName(includeName);
|
|
204
|
+
const content = resolveIncludeContent(normalizedName, options);
|
|
205
|
+
if (includeStack.includes(normalizedName)) {
|
|
206
|
+
const chain = [...includeStack, normalizedName].join(" -> ");
|
|
207
|
+
throw new Error(`Circular template include: ${chain}`);
|
|
208
|
+
}
|
|
209
|
+
return renderWithContext(content, context, partials, options, [
|
|
210
|
+
...includeStack,
|
|
211
|
+
normalizedName,
|
|
212
|
+
]);
|
|
166
213
|
});
|
|
167
214
|
}
|
|
168
215
|
|
|
@@ -176,10 +223,17 @@ function renderVariables(source, context) {
|
|
|
176
223
|
});
|
|
177
224
|
}
|
|
178
225
|
|
|
179
|
-
function renderWithContext(source, context, partials, includeStack = []) {
|
|
226
|
+
function renderWithContext(source, context, partials, options, includeStack = []) {
|
|
180
227
|
const afterIf = evaluateConditionals(source, context);
|
|
181
|
-
const
|
|
182
|
-
|
|
228
|
+
const afterPartials = renderPartials(afterIf, context, partials, options, includeStack);
|
|
229
|
+
const afterFileIncludes = renderFileIncludes(
|
|
230
|
+
afterPartials,
|
|
231
|
+
context,
|
|
232
|
+
partials,
|
|
233
|
+
options,
|
|
234
|
+
includeStack,
|
|
235
|
+
);
|
|
236
|
+
return renderVariables(afterFileIncludes, context);
|
|
183
237
|
}
|
|
184
238
|
|
|
185
239
|
function readAllTemplateFiles(rootDir, currentDir = rootDir) {
|
|
@@ -263,7 +317,34 @@ export function loadTemplatePartials(partialsDir) {
|
|
|
263
317
|
return partials;
|
|
264
318
|
}
|
|
265
319
|
|
|
320
|
+
export function loadSkillManifest(skillDir) {
|
|
321
|
+
const manifestPath = join(skillDir, "skill.json");
|
|
322
|
+
if (!existsSync(manifestPath)) return null;
|
|
323
|
+
|
|
324
|
+
const raw = readFileSync(manifestPath, "utf8");
|
|
325
|
+
return JSON.parse(raw);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function parseFrontmatterWithManifest(source, skillDir) {
|
|
329
|
+
const manifest = skillDir ? loadSkillManifest(skillDir) : null;
|
|
330
|
+
const { data: yamlData, body } = parseFrontmatter(source);
|
|
331
|
+
|
|
332
|
+
if (!manifest) return { data: yamlData, body };
|
|
333
|
+
|
|
334
|
+
const merged = { ...yamlData };
|
|
335
|
+
if (manifest.name) merged.name = manifest.name;
|
|
336
|
+
if (manifest.description) merged.description = manifest.description;
|
|
337
|
+
if (manifest.triggers) merged.triggers = manifest.triggers;
|
|
338
|
+
if (manifest.argument_hint) merged["argument-hint"] = manifest.argument_hint;
|
|
339
|
+
if (manifest.internal != null) merged.internal = manifest.internal;
|
|
340
|
+
|
|
341
|
+
return { data: merged, body };
|
|
342
|
+
}
|
|
343
|
+
|
|
266
344
|
export function renderSkillTemplate(template, context = {}, options = {}) {
|
|
267
|
-
const { partials = {} } = options;
|
|
268
|
-
return renderWithContext(template, context, partials
|
|
345
|
+
const { partials = {}, includes = {}, includeBaseDir = "" } = options;
|
|
346
|
+
return renderWithContext(template, context, partials, {
|
|
347
|
+
includeBaseDir,
|
|
348
|
+
includes: { ...includes },
|
|
349
|
+
});
|
|
269
350
|
}
|