triflux 10.14.3 → 10.16.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/README.md +15 -0
- package/bin/triflux.mjs +27 -3
- package/hub/intent.mjs +12 -7
- package/hub/lib/hosts-compat.mjs +66 -3
- package/hub/lib/state-snapshot.mjs +293 -0
- package/hub/server.mjs +5 -17
- 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-cli.mjs +26 -16
- 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/team/worker-signal.mjs +263 -0
- package/hub/team/worker-signal.types.d.ts +52 -0
- package/hub/workers/codex-mcp.mjs +106 -14
- package/package.json +4 -1
- package/scripts/check-codex-config-stable.mjs +122 -0
- package/scripts/headless-guard.mjs +6 -2
- package/scripts/hub-ensure.mjs +24 -0
- package/scripts/lib/mcp-health.mjs +4 -1
- package/scripts/release/bump-version.mjs +20 -12
- package/scripts/release/lib.mjs +7 -0
- package/scripts/release/prepare.mjs +8 -0
- package/scripts/setup.mjs +123 -10
- package/scripts/snapshot-codex-state.mjs +37 -0
- package/scripts/snapshot-gemini-state.mjs +37 -0
- package/scripts/sync-hub-mcp-settings.mjs +110 -13
- package/scripts/test-lock.mjs +8 -1
- package/scripts/tfx-route.sh +59 -48
- 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/skills/tfx-ship/SKILL.md +6 -2
- package/skills/tfx-workspace/async-tests/run-tests.sh +0 -203
- package/skills/tfx-workspace/evals/evals.json +0 -79
- package/skills/tfx-workspace/iteration-1/benchmark.json +0 -524
- package/skills/tfx-workspace/iteration-1/codex-gemini-remap/eval_metadata.json +0 -11
- package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/grading.json +0 -25
- package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/outputs/analysis.md +0 -154
- package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/grading.json +0 -25
- package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/outputs/analysis.md +0 -126
- package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-1/doctor-diagnosis/eval_metadata.json +0 -11
- package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/grading.json +0 -25
- package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/outputs/analysis.md +0 -119
- package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/grading.json +0 -25
- package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/outputs/analysis.md +0 -115
- package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-1/hub-start-sequence/eval_metadata.json +0 -10
- package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/grading.json +0 -20
- package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/outputs/analysis.md +0 -86
- package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/grading.json +0 -20
- package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/outputs/analysis.md +0 -81
- package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-1/multi-team-creation/eval_metadata.json +0 -12
- package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/grading.json +0 -30
- package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/outputs/analysis.md +0 -316
- package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/grading.json +0 -30
- package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/outputs/analysis.md +0 -352
- package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-1/review.html +0 -1325
- package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/eval_metadata.json +0 -12
- package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/grading.json +0 -30
- package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/outputs/analysis.md +0 -97
- package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/grading.json +0 -30
- package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/outputs/analysis.md +0 -94
- package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/eval_metadata.json +0 -12
- package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/grading.json +0 -30
- package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/outputs/analysis.md +0 -209
- package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/grading.json +0 -30
- package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/outputs/analysis.md +0 -193
- package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-2/benchmark.json +0 -144
- package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/eval_metadata.json +0 -13
- package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/grading.json +0 -35
- package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/outputs/analysis.md +0 -382
- package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/grading.json +0 -35
- package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/outputs/analysis.md +0 -333
- package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/timing.json +0 -5
- package/skills/tfx-workspace/iteration-2/review.html +0 -1325
- package/skills/tfx-workspace/skill-snapshot/tfx-auto/SKILL.md +0 -217
- package/skills/tfx-workspace/skill-snapshot/tfx-auto-codex/SKILL.md +0 -77
- package/skills/tfx-workspace/skill-snapshot/tfx-codex/SKILL.md +0 -65
- package/skills/tfx-workspace/skill-snapshot/tfx-doctor/SKILL.md +0 -94
- package/skills/tfx-workspace/skill-snapshot/tfx-gemini/SKILL.md +0 -82
- package/skills/tfx-workspace/skill-snapshot/tfx-hub/SKILL.md +0 -133
- package/skills/tfx-workspace/skill-snapshot/tfx-multi/SKILL.md +0 -426
- package/skills/tfx-workspace/skill-snapshot/tfx-setup/SKILL.md +0 -101
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/README.md
CHANGED
|
@@ -101,6 +101,21 @@ Then run `tfx setup` to configure your environment.
|
|
|
101
101
|
|
|
102
102
|
> **Note**: Deep skills require **psmux** (or tmux), **triflux Hub**, **Codex CLI**, and **Gemini CLI** for full Tri-CLI consensus. Without these, skills automatically degrade to Claude-only mode. Run `tfx doctor` to check your environment.
|
|
103
103
|
|
|
104
|
+
### State Snapshots
|
|
105
|
+
|
|
106
|
+
Hub startup also takes a best-effort daily snapshot of selected `~/.codex/` and
|
|
107
|
+
`~/.gemini/` state into `references/codex-snapshots/` and
|
|
108
|
+
`references/gemini-snapshots/`. Snapshot archives are rolling backups capped at
|
|
109
|
+
10 files per tool and are ignored by git.
|
|
110
|
+
|
|
111
|
+
Manual commands:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npm run snapshot:codex
|
|
115
|
+
npm run snapshot:gemini
|
|
116
|
+
npm run snapshot:all
|
|
117
|
+
```
|
|
118
|
+
|
|
104
119
|
---
|
|
105
120
|
|
|
106
121
|
## Core Engine
|
package/bin/triflux.mjs
CHANGED
|
@@ -5030,6 +5030,16 @@ function stopHubForUpdate() {
|
|
|
5030
5030
|
return info;
|
|
5031
5031
|
}
|
|
5032
5032
|
|
|
5033
|
+
function openHubLogFd() {
|
|
5034
|
+
try {
|
|
5035
|
+
const logDir = join(homedir(), ".claude", "cache", "tfx-hub");
|
|
5036
|
+
mkdirSync(logDir, { recursive: true });
|
|
5037
|
+
return openSync(join(logDir, "hub.log"), "a");
|
|
5038
|
+
} catch {
|
|
5039
|
+
return undefined;
|
|
5040
|
+
}
|
|
5041
|
+
}
|
|
5042
|
+
|
|
5033
5043
|
function startHubAfterUpdate(info) {
|
|
5034
5044
|
if (!info) return false;
|
|
5035
5045
|
const serverPath = join(PKG_ROOT, "hub", "server.mjs");
|
|
@@ -5040,13 +5050,19 @@ function startHubAfterUpdate(info) {
|
|
|
5040
5050
|
: String(process.env.TFX_HUB_PORT || "27888");
|
|
5041
5051
|
|
|
5042
5052
|
try {
|
|
5053
|
+
const logFd = openHubLogFd();
|
|
5043
5054
|
const child = spawn(process.execPath, [serverPath], {
|
|
5044
5055
|
env: { ...process.env, TFX_HUB_PORT: port },
|
|
5045
|
-
stdio: "ignore",
|
|
5056
|
+
stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore"],
|
|
5046
5057
|
detached: true,
|
|
5047
5058
|
windowsHide: true,
|
|
5048
5059
|
});
|
|
5049
5060
|
child.unref();
|
|
5061
|
+
if (logFd !== undefined) {
|
|
5062
|
+
try {
|
|
5063
|
+
closeSync(logFd);
|
|
5064
|
+
} catch {}
|
|
5065
|
+
}
|
|
5050
5066
|
return true;
|
|
5051
5067
|
} catch {
|
|
5052
5068
|
return false;
|
|
@@ -5211,7 +5227,9 @@ async function cmdHub(args = [], options = {}) {
|
|
|
5211
5227
|
});
|
|
5212
5228
|
}
|
|
5213
5229
|
|
|
5214
|
-
// Issue #102: spawn stderr 를
|
|
5230
|
+
// Issue #102 + hub-detach fix: spawn stdout/stderr 를 두 채널로 redirect.
|
|
5231
|
+
// - startupErrPath (tmp): 3초 안의 startup 실패 진단 (성공 시 cleanup)
|
|
5232
|
+
// - hub.log (cache): runtime stdout/stderr 영구 보존 (crash 추적)
|
|
5215
5233
|
// detached spawn 은 pipe 유지가 까다로우니 fd 리다이렉트로 접근.
|
|
5216
5234
|
const { openSync: _openSync, closeSync: _closeSync } = await import(
|
|
5217
5235
|
"node:fs"
|
|
@@ -5227,10 +5245,11 @@ async function cmdHub(args = [], options = {}) {
|
|
|
5227
5245
|
} catch {
|
|
5228
5246
|
errFd = undefined;
|
|
5229
5247
|
}
|
|
5248
|
+
const logFd = openHubLogFd();
|
|
5230
5249
|
|
|
5231
5250
|
const child = spawn(process.execPath, [serverPath], {
|
|
5232
5251
|
env: { ...process.env, TFX_HUB_PORT: port },
|
|
5233
|
-
stdio: ["ignore", "ignore", errFd ?? "ignore"],
|
|
5252
|
+
stdio: ["ignore", logFd ?? "ignore", errFd ?? logFd ?? "ignore"],
|
|
5234
5253
|
detached: true,
|
|
5235
5254
|
windowsHide: true,
|
|
5236
5255
|
});
|
|
@@ -5240,6 +5259,11 @@ async function cmdHub(args = [], options = {}) {
|
|
|
5240
5259
|
_closeSync(errFd);
|
|
5241
5260
|
} catch {}
|
|
5242
5261
|
}
|
|
5262
|
+
if (logFd !== undefined) {
|
|
5263
|
+
try {
|
|
5264
|
+
_closeSync(logFd);
|
|
5265
|
+
} catch {}
|
|
5266
|
+
}
|
|
5243
5267
|
|
|
5244
5268
|
// PID 파일 확인 (최대 3초 대기, 100ms 폴링)
|
|
5245
5269
|
let started = false;
|
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,293 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
copyFile,
|
|
4
|
+
mkdir,
|
|
5
|
+
readdir,
|
|
6
|
+
rename,
|
|
7
|
+
rm,
|
|
8
|
+
stat,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const DEFAULT_MAX_SNAPSHOTS = 10;
|
|
17
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
export const CODEX_STATE_INCLUDES = Object.freeze([
|
|
20
|
+
"config.toml",
|
|
21
|
+
"AGENTS.md",
|
|
22
|
+
"skills",
|
|
23
|
+
"agents",
|
|
24
|
+
"prompts",
|
|
25
|
+
"plugins",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const CODEX_STATE_EXCLUDES = Object.freeze([
|
|
29
|
+
"*.sqlite*",
|
|
30
|
+
".sandbox*",
|
|
31
|
+
".tmp",
|
|
32
|
+
"_archived_skills",
|
|
33
|
+
"memories",
|
|
34
|
+
"cache",
|
|
35
|
+
"log",
|
|
36
|
+
"logs",
|
|
37
|
+
"sessions",
|
|
38
|
+
"auth.json",
|
|
39
|
+
".credentials.json",
|
|
40
|
+
"*.bak*",
|
|
41
|
+
"*.tmp-*",
|
|
42
|
+
"cap_sid",
|
|
43
|
+
"installation_id",
|
|
44
|
+
"history.jsonl",
|
|
45
|
+
"models_cache.json",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export const GEMINI_STATE_INCLUDES = Object.freeze([
|
|
49
|
+
"settings.json",
|
|
50
|
+
"settings.local.json",
|
|
51
|
+
"GEMINI.md",
|
|
52
|
+
"commands",
|
|
53
|
+
"extensions",
|
|
54
|
+
"plugins",
|
|
55
|
+
"skills",
|
|
56
|
+
"agents",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
export const GEMINI_STATE_EXCLUDES = Object.freeze([
|
|
60
|
+
"*.sqlite*",
|
|
61
|
+
"cache",
|
|
62
|
+
"log",
|
|
63
|
+
"logs",
|
|
64
|
+
"sessions",
|
|
65
|
+
"auth.json",
|
|
66
|
+
".credentials.json",
|
|
67
|
+
"*.bak*",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
export const STATE_SNAPSHOT_THRESHOLD_MS = DAY_MS;
|
|
71
|
+
export const STATE_SNAPSHOT_MAX_SNAPSHOTS = DEFAULT_MAX_SNAPSHOTS;
|
|
72
|
+
|
|
73
|
+
function normalizePath(path) {
|
|
74
|
+
return String(path || "")
|
|
75
|
+
.replace(/\\/gu, "/")
|
|
76
|
+
.replace(/^\/+/u, "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function globToRegExp(pattern) {
|
|
80
|
+
const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/gu, "\\$&");
|
|
81
|
+
return new RegExp(`^${escaped.replace(/\*/gu, ".*")}$`, "u");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function compileExclude(pattern) {
|
|
85
|
+
const text = normalizePath(pattern).replace(/\/+$/u, "");
|
|
86
|
+
if (text.includes("*")) {
|
|
87
|
+
const regex = globToRegExp(text);
|
|
88
|
+
return (relativePath) => {
|
|
89
|
+
const normalized = normalizePath(relativePath);
|
|
90
|
+
return normalized.split("/").some((part) => regex.test(part));
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (relativePath) => {
|
|
95
|
+
const normalized = normalizePath(relativePath);
|
|
96
|
+
return normalized.split("/").includes(text);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isSubpath(parent, child) {
|
|
101
|
+
const rel = relative(parent, child);
|
|
102
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function pathStats(path) {
|
|
106
|
+
try {
|
|
107
|
+
return await stat(path);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error?.code === "ENOENT") return null;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function collectFiles({ sourceDir, includes, excludeMatchers }) {
|
|
115
|
+
const sourceRoot = resolve(sourceDir);
|
|
116
|
+
const files = [];
|
|
117
|
+
|
|
118
|
+
async function visit(absPath, relativePath) {
|
|
119
|
+
if (excludeMatchers.some((matcher) => matcher(relativePath))) return;
|
|
120
|
+
|
|
121
|
+
const info = await pathStats(absPath);
|
|
122
|
+
if (!info) return;
|
|
123
|
+
if (info.isDirectory()) {
|
|
124
|
+
const children = await readdir(absPath, { withFileTypes: true });
|
|
125
|
+
for (const child of children) {
|
|
126
|
+
await visit(join(absPath, child.name), join(relativePath, child.name));
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (info.isFile()) {
|
|
131
|
+
files.push({
|
|
132
|
+
absPath,
|
|
133
|
+
relativePath: normalizePath(relativePath),
|
|
134
|
+
size: info.size,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const include of includes || []) {
|
|
140
|
+
const relativeInclude = normalizePath(include);
|
|
141
|
+
if (!relativeInclude || relativeInclude.startsWith("../")) continue;
|
|
142
|
+
const absPath = resolve(sourceRoot, relativeInclude);
|
|
143
|
+
if (!isSubpath(sourceRoot, absPath)) continue;
|
|
144
|
+
await visit(absPath, relativeInclude);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
148
|
+
return files;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function listSnapshots(destDir) {
|
|
152
|
+
const names = await readdir(destDir).catch((error) => {
|
|
153
|
+
if (error?.code === "ENOENT") return [];
|
|
154
|
+
throw error;
|
|
155
|
+
});
|
|
156
|
+
const snapshots = [];
|
|
157
|
+
for (const name of names) {
|
|
158
|
+
if (!name.endsWith(".tar.gz")) continue;
|
|
159
|
+
const path = join(destDir, name);
|
|
160
|
+
const info = await pathStats(path);
|
|
161
|
+
if (info?.isFile()) snapshots.push({ name, path, mtimeMs: info.mtimeMs });
|
|
162
|
+
}
|
|
163
|
+
snapshots.sort(
|
|
164
|
+
(a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name),
|
|
165
|
+
);
|
|
166
|
+
return snapshots;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function copyToStaging(files, stagingDir) {
|
|
170
|
+
for (const file of files) {
|
|
171
|
+
const targetPath = join(stagingDir, ...file.relativePath.split("/"));
|
|
172
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
173
|
+
await copyFile(file.absPath, targetPath);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function createArchive({ stagingDir, archivePath, files }) {
|
|
178
|
+
const listPath = join(stagingDir, ".snapshot-files");
|
|
179
|
+
const archiveName = normalizePath(relative(stagingDir, archivePath));
|
|
180
|
+
await writeFile(
|
|
181
|
+
listPath,
|
|
182
|
+
`${files.map((file) => file.relativePath).join("\n")}\n`,
|
|
183
|
+
"utf8",
|
|
184
|
+
);
|
|
185
|
+
await execFileAsync(
|
|
186
|
+
"tar",
|
|
187
|
+
["-czf", archiveName, "-C", ".", "-T", ".snapshot-files"],
|
|
188
|
+
{
|
|
189
|
+
cwd: stagingDir,
|
|
190
|
+
windowsHide: true,
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
await rm(listPath, { force: true });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function pruneSnapshots(destDir, maxSnapshots) {
|
|
197
|
+
const snapshots = await listSnapshots(destDir);
|
|
198
|
+
const keep = Math.max(1, Number(maxSnapshots) || DEFAULT_MAX_SNAPSHOTS);
|
|
199
|
+
for (const snapshot of snapshots.slice(keep)) {
|
|
200
|
+
await rm(snapshot.path, { force: true });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function formatStamp(date) {
|
|
205
|
+
return date
|
|
206
|
+
.toISOString()
|
|
207
|
+
.replace(/[-:]/gu, "")
|
|
208
|
+
.replace(/\.\d{3}Z$/u, "Z");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function uniqueSuffix() {
|
|
212
|
+
return `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Snapshot selected user state into a rolling tar.gz archive.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} options
|
|
219
|
+
* @param {string} options.sourceDir
|
|
220
|
+
* @param {string} options.destDir
|
|
221
|
+
* @param {string[]} options.includes
|
|
222
|
+
* @param {string[]} options.excludes
|
|
223
|
+
* @param {number} options.thresholdMs
|
|
224
|
+
* @param {number} [options.maxSnapshots=10]
|
|
225
|
+
* @returns {Promise<{skipped: boolean, reason?: string, path?: string, sizeBytes?: number, fileCount?: number}>}
|
|
226
|
+
*/
|
|
227
|
+
export async function snapshotState({
|
|
228
|
+
sourceDir,
|
|
229
|
+
destDir,
|
|
230
|
+
includes,
|
|
231
|
+
excludes = [],
|
|
232
|
+
thresholdMs = 0,
|
|
233
|
+
maxSnapshots = DEFAULT_MAX_SNAPSHOTS,
|
|
234
|
+
}) {
|
|
235
|
+
const sourceRoot = resolve(sourceDir || "");
|
|
236
|
+
const destRoot = resolve(destDir || "");
|
|
237
|
+
const sourceInfo = await pathStats(sourceRoot);
|
|
238
|
+
if (!sourceInfo?.isDirectory()) {
|
|
239
|
+
return { skipped: true, reason: "source-missing" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await mkdir(destRoot, { recursive: true });
|
|
243
|
+
const snapshots = await listSnapshots(destRoot);
|
|
244
|
+
const newest = snapshots[0];
|
|
245
|
+
if (
|
|
246
|
+
newest &&
|
|
247
|
+
Number(thresholdMs) > 0 &&
|
|
248
|
+
Date.now() - newest.mtimeMs < Number(thresholdMs)
|
|
249
|
+
) {
|
|
250
|
+
return { skipped: true, reason: "threshold", path: newest.path };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const excludeMatchers = excludes.map((pattern) => compileExclude(pattern));
|
|
254
|
+
const files = await collectFiles({
|
|
255
|
+
sourceDir: sourceRoot,
|
|
256
|
+
includes,
|
|
257
|
+
excludeMatchers,
|
|
258
|
+
});
|
|
259
|
+
if (files.length === 0) {
|
|
260
|
+
return { skipped: true, reason: "empty" };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const suffix = uniqueSuffix();
|
|
264
|
+
const stagingDir = join(tmpdir(), `tfx-state-snapshot-${suffix}`);
|
|
265
|
+
const tempArchivePath = join(stagingDir, `.state-${suffix}.tar.gz.tmp`);
|
|
266
|
+
const finalArchivePath = join(
|
|
267
|
+
destRoot,
|
|
268
|
+
`state-${formatStamp(new Date())}-${suffix}.tar.gz`,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
await mkdir(stagingDir, { recursive: true });
|
|
273
|
+
await copyToStaging(files, stagingDir);
|
|
274
|
+
await createArchive({
|
|
275
|
+
stagingDir,
|
|
276
|
+
archivePath: tempArchivePath,
|
|
277
|
+
files,
|
|
278
|
+
});
|
|
279
|
+
await rename(tempArchivePath, finalArchivePath);
|
|
280
|
+
await pruneSnapshots(destRoot, maxSnapshots);
|
|
281
|
+
const archiveInfo = await stat(finalArchivePath);
|
|
282
|
+
const sizeBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
283
|
+
return {
|
|
284
|
+
skipped: false,
|
|
285
|
+
path: finalArchivePath,
|
|
286
|
+
sizeBytes: archiveInfo.size || sizeBytes,
|
|
287
|
+
fileCount: files.length,
|
|
288
|
+
};
|
|
289
|
+
} finally {
|
|
290
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
291
|
+
await rm(tempArchivePath, { force: true });
|
|
292
|
+
}
|
|
293
|
+
}
|
package/hub/server.mjs
CHANGED
|
@@ -140,7 +140,7 @@ export async function tryReuseExistingHub({
|
|
|
140
140
|
} = {}) {
|
|
141
141
|
const existing = readCurrentState();
|
|
142
142
|
const existingPort = Number(existing?.port);
|
|
143
|
-
const requestedPort = parseHubPort(port);
|
|
143
|
+
const requestedPort = parseHubPort(port) ?? HUB_DEFAULT_PORT;
|
|
144
144
|
const livePeer = detectPeer();
|
|
145
145
|
const livePidPort = parseHubPort(livePeer?.port);
|
|
146
146
|
if (
|
|
@@ -150,11 +150,7 @@ export async function tryReuseExistingHub({
|
|
|
150
150
|
) {
|
|
151
151
|
return null;
|
|
152
152
|
}
|
|
153
|
-
if (
|
|
154
|
-
if (portSpecified) return null;
|
|
155
|
-
if (!livePeer?.alive || !livePidPort || existingPort !== livePidPort) {
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
153
|
+
if (existingPort !== requestedPort) {
|
|
158
154
|
log.warn(
|
|
159
155
|
{
|
|
160
156
|
requestedPort,
|
|
@@ -162,8 +158,9 @@ export async function tryReuseExistingHub({
|
|
|
162
158
|
pid: livePeer.pid,
|
|
163
159
|
livePidPort,
|
|
164
160
|
},
|
|
165
|
-
"hub.
|
|
161
|
+
"hub.port_mismatch_not_reusing_live_pid",
|
|
166
162
|
);
|
|
163
|
+
return null;
|
|
167
164
|
}
|
|
168
165
|
if (!(await checkHealth(existingPort))) return null;
|
|
169
166
|
|
|
@@ -237,18 +234,9 @@ function readHubPidFile(
|
|
|
237
234
|
}
|
|
238
235
|
|
|
239
236
|
export function resolveHubPort(env = process.env, opts = {}) {
|
|
240
|
-
|
|
241
|
-
preferLivePid = true,
|
|
242
|
-
detectPeer = detectLivePeer,
|
|
243
|
-
pidFilePath = PID_FILE,
|
|
244
|
-
} = opts;
|
|
237
|
+
void opts;
|
|
245
238
|
const envPort = parseHubPort(env?.TFX_HUB_PORT);
|
|
246
239
|
if (envPort) return envPort;
|
|
247
|
-
if (preferLivePid) {
|
|
248
|
-
const peer = detectPeer(pidFilePath);
|
|
249
|
-
const peerPort = parseHubPort(peer?.port);
|
|
250
|
-
if (peer?.alive && peerPort) return peerPort;
|
|
251
|
-
}
|
|
252
240
|
return HUB_DEFAULT_PORT;
|
|
253
241
|
}
|
|
254
242
|
|
|
@@ -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
|
|