triflux 10.25.0 → 10.25.1
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/bin/triflux.mjs +62 -0
- package/config/mcp-registry.json +85 -0
- package/config/routing-policy.json +124 -0
- package/hooks/safety-guard.mjs +8 -1
- package/hub/team/claude-daemon-control.mjs +59 -0
- package/hub/team/claude-native-bridge.mjs +211 -0
- package/hub/team/cli/commands/start/index.mjs +4 -1
- package/hub/team/cli/commands/start/parse-args.mjs +32 -2
- package/hub/team/headless.mjs +20 -2
- package/hub/team/swarm-cli.mjs +15 -0
- package/hub/team/swarm-hypervisor.mjs +171 -7
- package/hub/team/worktree-lifecycle.mjs +4 -16
- package/package.json +4 -3
- package/scripts/__tests__/install-mcp-gateway-startup.test.mjs +24 -0
- package/scripts/__tests__/mcp-gateway-health-check.test.mjs +208 -0
- package/scripts/__tests__/mcp-gateway-wrapper-check.test.mjs +54 -0
- package/scripts/__tests__/release-governance.test.mjs +161 -1
- package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +35 -0
- package/scripts/doctor-diagnose.mjs +14 -8
- package/scripts/install-mcp-gateway-startup.mjs +3 -1
- package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
- package/scripts/pack.mjs +23 -18
- package/scripts/release/check-packages-mirror.mjs +20 -4
- package/scripts/release/prepare.mjs +2 -1
- package/scripts/release/publish.mjs +100 -7
- package/scripts/release/verify.mjs +39 -13
- package/scripts/release/version-manifest.json +8 -0
- package/scripts/tfx-route.mjs +13 -0
- package/scripts/tfx-route.sh +44 -12
- package/skills/tfx-analysis/SKILL.md +12 -12
- package/skills/tfx-auto/SKILL.md +14 -12
- package/skills/tfx-auto/SKILL.md.tmpl +5 -5
- package/skills/tfx-consensus/SKILL.md.tmpl +8 -8
- package/skills/tfx-debate/SKILL.md +1 -1
- package/skills/tfx-debate/SKILL.md.tmpl +5 -5
- package/skills/tfx-doctor/SKILL.md +5 -5
- package/skills/tfx-doctor/SKILL.md.tmpl +5 -5
- package/skills/tfx-fullcycle/SKILL.md.tmpl +8 -8
- package/skills/tfx-hub/SKILL.md +2 -2
- package/skills/tfx-hub/SKILL.md.tmpl +2 -2
- package/skills/tfx-index/SKILL.md +9 -9
- package/skills/tfx-index/SKILL.md.tmpl +9 -9
- package/skills/tfx-interview/SKILL.md +29 -29
- package/skills/tfx-interview/SKILL.md.tmpl +24 -24
- package/skills/tfx-multi/SKILL.md.tmpl +4 -4
- package/skills/tfx-multi/references/agent-wrapper-rules.md +3 -3
- package/skills/tfx-panel/SKILL.md.tmpl +5 -5
- package/skills/tfx-persist/SKILL.md.tmpl +15 -15
- package/skills/tfx-plan/SKILL.md +14 -14
- package/skills/tfx-plan/SKILL.md.tmpl +6 -6
- package/skills/tfx-plan/skill.json +1 -1
- package/skills/tfx-profile/SKILL.md +10 -10
- package/skills/tfx-profile/SKILL.md.tmpl +10 -10
- package/skills/tfx-profile/skill.json +1 -1
- package/skills/tfx-prune/SKILL.md +4 -4
- package/skills/tfx-prune/SKILL.md.tmpl +4 -4
- package/skills/tfx-qa/SKILL.md +4 -4
- package/skills/tfx-research/SKILL.md +9 -9
- package/skills/tfx-research/SKILL.md.tmpl +12 -12
- package/skills/tfx-research/skill.json +1 -1
- package/skills/tfx-review/SKILL.md +13 -13
- package/skills/tfx-setup/SKILL.md +25 -8
- package/skills/tfx-setup/SKILL.md.tmpl +8 -8
- package/skills/tfx-ship/SKILL.md +4 -5
- package/skills/tfx-swarm/SKILL.md.tmpl +4 -4
package/bin/triflux.mjs
CHANGED
|
@@ -4670,6 +4670,68 @@ async function cmdDoctor(options = {}) {
|
|
|
4670
4670
|
}
|
|
4671
4671
|
}
|
|
4672
4672
|
|
|
4673
|
+
// ── MCP Gateway Health ──
|
|
4674
|
+
// install-mcp-gateway-startup 으로 띄운 LaunchAgent/systemd daemon 의 stdout
|
|
4675
|
+
// (~/.local/state/triflux/mcp-gateway.out.log) 를 파싱해 missing-env 등으로
|
|
4676
|
+
// skip 된 server 를 잡는다. 로그가 없으면 gateway 미설치/미실행으로 침묵.
|
|
4677
|
+
section("MCP Gateway Health");
|
|
4678
|
+
{
|
|
4679
|
+
const { checkMcpGatewayHealth, summarizeMcpGatewayHealth } = await import(
|
|
4680
|
+
"../scripts/lib/mcp-gateway-health-check.mjs"
|
|
4681
|
+
);
|
|
4682
|
+
const gatewayHealth = checkMcpGatewayHealth();
|
|
4683
|
+
const summary = summarizeMcpGatewayHealth(gatewayHealth);
|
|
4684
|
+
addDoctorCheck(report, {
|
|
4685
|
+
name: "mcp-gateway-health",
|
|
4686
|
+
status: summary.level === "warn" ? "warning" : "ok",
|
|
4687
|
+
log_path: gatewayHealth.logPath,
|
|
4688
|
+
findings: gatewayHealth.findings,
|
|
4689
|
+
started: gatewayHealth.started,
|
|
4690
|
+
skipped: gatewayHealth.skipped,
|
|
4691
|
+
...(summary.fix ? { fix: summary.fix } : {}),
|
|
4692
|
+
});
|
|
4693
|
+
if (summary.level === "skip") {
|
|
4694
|
+
info(summary.message);
|
|
4695
|
+
} else if (summary.level === "ok") {
|
|
4696
|
+
ok(summary.message);
|
|
4697
|
+
} else {
|
|
4698
|
+
warn(summary.message);
|
|
4699
|
+
if (summary.fix) info(`수정: ${summary.fix}`);
|
|
4700
|
+
issues++;
|
|
4701
|
+
}
|
|
4702
|
+
}
|
|
4703
|
+
|
|
4704
|
+
// ── MCP Gateway Wrapper ──
|
|
4705
|
+
// 로그가 생기기 전 단계에서 wrapper 자체가 secrets.env 를 source 하는지 확인한다.
|
|
4706
|
+
section("MCP Gateway Wrapper");
|
|
4707
|
+
{
|
|
4708
|
+
const { checkWrapperSourcing } = await import(
|
|
4709
|
+
"../scripts/lib/mcp-gateway-wrapper-check.mjs"
|
|
4710
|
+
);
|
|
4711
|
+
const wrapperCheck = await checkWrapperSourcing();
|
|
4712
|
+
addDoctorCheck(report, {
|
|
4713
|
+
name: "mcp-gateway-wrapper-sourcing",
|
|
4714
|
+
status:
|
|
4715
|
+
wrapperCheck.status === "warn" ? "warning" : wrapperCheck.status,
|
|
4716
|
+
path: wrapperCheck.wrapperPath,
|
|
4717
|
+
...(wrapperCheck.message ? { message: wrapperCheck.message } : {}),
|
|
4718
|
+
...(wrapperCheck.suggestedFix
|
|
4719
|
+
? { fix: wrapperCheck.suggestedFix }
|
|
4720
|
+
: {}),
|
|
4721
|
+
});
|
|
4722
|
+
|
|
4723
|
+
if (wrapperCheck.status === "ok") {
|
|
4724
|
+
ok("wrapper sources secrets.env");
|
|
4725
|
+
} else if (wrapperCheck.status === "warn") {
|
|
4726
|
+
warn(wrapperCheck.message);
|
|
4727
|
+
info(`수정: ${wrapperCheck.suggestedFix}`);
|
|
4728
|
+
issues++;
|
|
4729
|
+
} else {
|
|
4730
|
+
warn("mcp-gateway wrapper not installed");
|
|
4731
|
+
if (wrapperCheck.message) info(wrapperCheck.message);
|
|
4732
|
+
}
|
|
4733
|
+
}
|
|
4734
|
+
|
|
4673
4735
|
// ── Codex Config Health (BUG-H #132) ──
|
|
4674
4736
|
// _codex_config_swap 의 restore 가 Windows lock/ACL 로 실패하면
|
|
4675
4737
|
// ~/.codex/config.toml.pre-exec 가 남아 [mcp_servers.*] 섹션이 영구 손실된다.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "mcp-registry-schema",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"description": "MCP 서버 중앙 레지스트리 — 진실의 원천",
|
|
5
|
+
"defaults": {
|
|
6
|
+
"transport": "hub-url",
|
|
7
|
+
"hub_base": "http://127.0.0.1:27888"
|
|
8
|
+
},
|
|
9
|
+
"policy_notes": {
|
|
10
|
+
"policy": "Server policy is the SSOT for client sync shape: hosted writes url+headers, gateway-sse writes http://127.0.0.1:81XX/sse with SSE metadata, and stdio writes command/args/env.",
|
|
11
|
+
"transport": "Server transport accepts \"hub-url\" for triflux Hub URL flow, \"http\" for direct Streamable HTTP MCP endpoints, or \"stdio\" for upstream-stdio-only MCP servers. Gateway-backed stdio upstreams use policy:\"gateway-sse\" plus gateway_port so clients receive URL/SSE config.",
|
|
12
|
+
"headers": "Optional headers are allowed only for HTTP-compatible transports. Each header value must be a descriptor: {\"value\":\"literal\"} for non-secret static values, {\"env\":\"ENV_VAR_NAME\"} for secrets resolved at sync/runtime, or {\"env\":\"ENV_VAR_NAME\",\"prefix\":\"Bearer \"} for common authorization formats.",
|
|
13
|
+
"secret_safety": "Resolved secret values must not be written back to this registry file. Missing env vars warn during sync and do not emit empty secret headers.",
|
|
14
|
+
"sync_denylist": "Array of client:server strings skipped by proactive registry sync, for example gemini:tfx-hub."
|
|
15
|
+
},
|
|
16
|
+
"servers": {
|
|
17
|
+
"tfx-hub": {
|
|
18
|
+
"policy": "hosted",
|
|
19
|
+
"transport": "hub-url",
|
|
20
|
+
"url": "http://127.0.0.1:27888/mcp",
|
|
21
|
+
"safe": true,
|
|
22
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
23
|
+
"description": "triflux Hub MCP 서버"
|
|
24
|
+
},
|
|
25
|
+
"context7": {
|
|
26
|
+
"policy": "hosted",
|
|
27
|
+
"transport": "http",
|
|
28
|
+
"url": "https://mcp.context7.com/mcp",
|
|
29
|
+
"safe": true,
|
|
30
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
31
|
+
"description": "Upstash Context7 — 라이브러리 문서/코드 컨텍스트 (HTTP MCP, API key 불필요)"
|
|
32
|
+
},
|
|
33
|
+
"exa": {
|
|
34
|
+
"policy": "hosted",
|
|
35
|
+
"transport": "http",
|
|
36
|
+
"url": "https://mcp.exa.ai/mcp",
|
|
37
|
+
"headers": {
|
|
38
|
+
"Authorization": { "env": "EXA_API_KEY", "prefix": "Bearer " }
|
|
39
|
+
},
|
|
40
|
+
"safe": true,
|
|
41
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
42
|
+
"description": "Exa neural/semantic web search — 학술/기술 깊이. Key 발급: https://exa.ai/dashboard → secrets.env의 EXA_API_KEY"
|
|
43
|
+
},
|
|
44
|
+
"serena": {
|
|
45
|
+
"policy": "gateway-sse",
|
|
46
|
+
"gateway_port": 8105,
|
|
47
|
+
"safe": true,
|
|
48
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
49
|
+
"description": "Serena MCP — supergateway singleton SSE endpoint (:8105)"
|
|
50
|
+
},
|
|
51
|
+
"brave-search": {
|
|
52
|
+
"policy": "gateway-sse",
|
|
53
|
+
"gateway_port": 8101,
|
|
54
|
+
"safe": true,
|
|
55
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
56
|
+
"description": "Brave Search MCP — supergateway singleton SSE endpoint (:8101). BRAVE_API_KEY 환경변수 필요 (https://brave.com/search/api/). secrets.env 의 BRAVE_API_KEY 참조."
|
|
57
|
+
},
|
|
58
|
+
"tavily": {
|
|
59
|
+
"policy": "hosted",
|
|
60
|
+
"transport": "http",
|
|
61
|
+
"url": "https://mcp.tavily.com/mcp",
|
|
62
|
+
"headers": {
|
|
63
|
+
"Authorization": { "env": "TAVILY_API_KEY", "prefix": "Bearer " }
|
|
64
|
+
},
|
|
65
|
+
"safe": true,
|
|
66
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
67
|
+
"description": "Tavily research — 비용/운영/DX/일반 웹. Key 발급: https://app.tavily.com/home → secrets.env의 TAVILY_API_KEY"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"policies": {
|
|
71
|
+
"stdio_action": "replace-with-hub",
|
|
72
|
+
"unknown_server_action": "warn",
|
|
73
|
+
"sync_denylist": [],
|
|
74
|
+
"watched_paths": [
|
|
75
|
+
"~/.gemini/settings.json",
|
|
76
|
+
"~/.codex/config.toml",
|
|
77
|
+
"~/.claude.json",
|
|
78
|
+
"~/.claude/settings.json",
|
|
79
|
+
"~/.claude/settings.local.json",
|
|
80
|
+
".claude/mcp.json",
|
|
81
|
+
".mcp.json",
|
|
82
|
+
"~/.gemini/config/mcp_config.json"
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"freshness": {
|
|
4
|
+
"max_snapshot_age_ms": 900000,
|
|
5
|
+
"max_cross_provider_skew_ms": 300000,
|
|
6
|
+
"local_clock_skew_guard_ms": 60000
|
|
7
|
+
},
|
|
8
|
+
"thresholds": {
|
|
9
|
+
"reset_imminent_ms": 86400000,
|
|
10
|
+
"exhaustion_high_percent": 80,
|
|
11
|
+
"exhaustion_low_percent": 20,
|
|
12
|
+
"expiry_drain_window_ms": 604800000,
|
|
13
|
+
"drain_usage_ceiling_percent": 50
|
|
14
|
+
},
|
|
15
|
+
"scenarios": [
|
|
16
|
+
{
|
|
17
|
+
"id": "S1",
|
|
18
|
+
"name": "claude-reset-imminent-drain",
|
|
19
|
+
"when": {
|
|
20
|
+
"provider": "claude",
|
|
21
|
+
"weeklyResetsAtLteMs": 86400000,
|
|
22
|
+
"weeklyPercentLt": 100,
|
|
23
|
+
"hasMultipleAccounts": true
|
|
24
|
+
},
|
|
25
|
+
"then": {
|
|
26
|
+
"lane": "multi",
|
|
27
|
+
"primary_cli": "claude",
|
|
28
|
+
"reserve_codex_share": 0.1,
|
|
29
|
+
"reason": "claude-weekly-reset-imminent-drain-fleet"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "S2",
|
|
34
|
+
"name": "codex-reset-imminent-drain",
|
|
35
|
+
"when": {
|
|
36
|
+
"provider": "codex",
|
|
37
|
+
"weeklyResetsAtLteMs": 86400000,
|
|
38
|
+
"weeklyPercentLt": 100
|
|
39
|
+
},
|
|
40
|
+
"then": {
|
|
41
|
+
"lane": "swarm",
|
|
42
|
+
"primary_cli": "codex",
|
|
43
|
+
"reserve_claude_share": 0.1,
|
|
44
|
+
"reason": "codex-weekly-reset-imminent-drain-fleet"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "S3",
|
|
49
|
+
"name": "claude-burned-codex-fresh",
|
|
50
|
+
"when": {
|
|
51
|
+
"claude_weekly_percent_gte": 80,
|
|
52
|
+
"codex_weekly_percent_lt": 20,
|
|
53
|
+
"claude_weekly_resets_at_gt_ms": 172800000
|
|
54
|
+
},
|
|
55
|
+
"then": {
|
|
56
|
+
"lane": "single",
|
|
57
|
+
"primary_cli": "codex",
|
|
58
|
+
"claude_lanes_allowed": ["explore", "final-challenge"],
|
|
59
|
+
"reason": "claude-burned-codex-fresh-rebalance"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "S4",
|
|
64
|
+
"name": "claude-multi-account-staggered",
|
|
65
|
+
"when": {
|
|
66
|
+
"provider": "claude",
|
|
67
|
+
"accountCountGte": 2,
|
|
68
|
+
"staggered": true
|
|
69
|
+
},
|
|
70
|
+
"then": {
|
|
71
|
+
"lane": "multi",
|
|
72
|
+
"primary_cli": "claude",
|
|
73
|
+
"rank_by": "expiring_capacity_score",
|
|
74
|
+
"reason": "claude-multi-account-staggered-reset"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": "S5",
|
|
79
|
+
"name": "claude-parallel-leasemany",
|
|
80
|
+
"when": {
|
|
81
|
+
"provider": "claude",
|
|
82
|
+
"request_team_size_gte": 2,
|
|
83
|
+
"accountCountGte": 2
|
|
84
|
+
},
|
|
85
|
+
"then": {
|
|
86
|
+
"lane": "swarm",
|
|
87
|
+
"primary_cli": "claude",
|
|
88
|
+
"atomic": "leaseMany",
|
|
89
|
+
"atomic_policy": "balanced",
|
|
90
|
+
"reason": "claude-parallel-multi-account"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"id": "S6",
|
|
95
|
+
"name": "account-expiry-drain",
|
|
96
|
+
"when": {
|
|
97
|
+
"expiresAtLteMs": 604800000,
|
|
98
|
+
"weeklyPercentLt": 50,
|
|
99
|
+
"circuitStateNotOpen": true
|
|
100
|
+
},
|
|
101
|
+
"then": {
|
|
102
|
+
"lane": "single",
|
|
103
|
+
"riskTier": "drain",
|
|
104
|
+
"elevated_weight": 2.0,
|
|
105
|
+
"reason": "account-expiry-drain-before-loss"
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
"fallback": {
|
|
110
|
+
"default_mode": "codex-default",
|
|
111
|
+
"default_cli": "codex",
|
|
112
|
+
"default_model": "gpt-5.5",
|
|
113
|
+
"triggers": [
|
|
114
|
+
"hud_null",
|
|
115
|
+
"hud_stale_gte_15min",
|
|
116
|
+
"weekly_reset_in_past",
|
|
117
|
+
"cross_provider_skew_exceeded",
|
|
118
|
+
"broker_empty",
|
|
119
|
+
"all_circuits_open",
|
|
120
|
+
"mirror_drift",
|
|
121
|
+
"flag_disabled"
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
}
|
package/hooks/safety-guard.mjs
CHANGED
|
@@ -340,7 +340,14 @@ function main() {
|
|
|
340
340
|
return hasSegmentInvocation(cmd, WT_DIRECT_PATTERNS);
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
-
|
|
343
|
+
// wt.exe는 Windows Terminal 전용. macOS/Linux에서는 PATH에 존재하지 않아
|
|
344
|
+
// 차단해도 dormant이며, 문서/로그 텍스트의 wt 단어가 false positive로 잡힐
|
|
345
|
+
// 위험만 있다. win32에서만 차단 적용.
|
|
346
|
+
// SAFETY_GUARD_FORCE_PLATFORM env로 테스트에서 platform 시뮬레이션 가능
|
|
347
|
+
// (mac CI에서 win32 차단 동작을 검증하기 위함).
|
|
348
|
+
const guardPlatform =
|
|
349
|
+
process.env.SAFETY_GUARD_FORCE_PLATFORM || process.platform;
|
|
350
|
+
if (guardPlatform === "win32" && isWtDirectInvocation(command)) {
|
|
344
351
|
blockCommand(WT_DIRECT_BLOCK_MESSAGE, command);
|
|
345
352
|
}
|
|
346
353
|
|
|
@@ -133,6 +133,23 @@ export function findDaemonJobByShort(listResponse, short) {
|
|
|
133
133
|
return listResponse.jobs.find((job) => job?.short === short) || null;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
export function findDaemonJobBySessionId(listResponse, sessionId) {
|
|
137
|
+
if (!Array.isArray(listResponse?.jobs)) return null;
|
|
138
|
+
const expected = String(sessionId || "");
|
|
139
|
+
if (!expected) return null;
|
|
140
|
+
return (
|
|
141
|
+
listResponse.jobs.find((job) => {
|
|
142
|
+
const candidate =
|
|
143
|
+
job?.sessionId ??
|
|
144
|
+
job?.session_id ??
|
|
145
|
+
job?.dispatch?.sessionId ??
|
|
146
|
+
job?.d?.sessionId ??
|
|
147
|
+
"";
|
|
148
|
+
return String(candidate) === expected;
|
|
149
|
+
}) || null
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
136
153
|
export async function waitForDaemonJobPid(
|
|
137
154
|
controlSock,
|
|
138
155
|
short,
|
|
@@ -158,3 +175,45 @@ export async function killDaemonJob(controlSock, short) {
|
|
|
158
175
|
short,
|
|
159
176
|
});
|
|
160
177
|
}
|
|
178
|
+
|
|
179
|
+
export async function sendKillBySessionId({
|
|
180
|
+
daemonPaths,
|
|
181
|
+
sessionId,
|
|
182
|
+
timeoutMs = 6000,
|
|
183
|
+
} = {}) {
|
|
184
|
+
const controlSock = daemonPaths?.controlSock;
|
|
185
|
+
if (!controlSock || !sessionId) {
|
|
186
|
+
return { ok: true, killed: false, reason: "missing_target" };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const list = await sendClaudeControlRequest(
|
|
191
|
+
controlSock,
|
|
192
|
+
{
|
|
193
|
+
proto: 1,
|
|
194
|
+
op: "list",
|
|
195
|
+
},
|
|
196
|
+
{ timeoutMs },
|
|
197
|
+
);
|
|
198
|
+
const job = findDaemonJobBySessionId(list, sessionId);
|
|
199
|
+
if (!job?.short) {
|
|
200
|
+
return { ok: true, killed: false, reason: "not_found" };
|
|
201
|
+
}
|
|
202
|
+
return await sendClaudeControlRequest(
|
|
203
|
+
controlSock,
|
|
204
|
+
{
|
|
205
|
+
proto: 1,
|
|
206
|
+
op: "kill",
|
|
207
|
+
short: job.short,
|
|
208
|
+
},
|
|
209
|
+
{ timeoutMs },
|
|
210
|
+
);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
killed: false,
|
|
215
|
+
reason: "control_unavailable",
|
|
216
|
+
error: error?.message || String(error),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -4,6 +4,17 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import net from "node:net";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
buildDaemonExecDispatchPayload,
|
|
9
|
+
killDaemonJob,
|
|
10
|
+
sendClaudeControlRequest,
|
|
11
|
+
waitForDaemonJobPid,
|
|
12
|
+
} from "./claude-daemon-control.mjs";
|
|
13
|
+
import {
|
|
14
|
+
buildClaudeSessionProjection,
|
|
15
|
+
removeClaudeSessionProjection,
|
|
16
|
+
writeClaudeSessionProjection,
|
|
17
|
+
} from "./claude-session-projection.mjs";
|
|
7
18
|
|
|
8
19
|
const DEFAULT_ROWS = 40;
|
|
9
20
|
const DEFAULT_COLS = 120;
|
|
@@ -36,6 +47,8 @@ export function deriveClaudeDaemonPaths({
|
|
|
36
47
|
rendezvousDir: path.join(daemonDir, "rv"),
|
|
37
48
|
ptyDir: path.join(daemonDir, "pty"),
|
|
38
49
|
rosterPath: path.join(resolvedConfigDir, "daemon", "roster.json"),
|
|
50
|
+
sessionsDir: path.join(resolvedConfigDir, "sessions"),
|
|
51
|
+
jobsDir: path.join(resolvedConfigDir, "jobs"),
|
|
39
52
|
};
|
|
40
53
|
}
|
|
41
54
|
|
|
@@ -203,6 +216,204 @@ export async function removeRosterWorkers(rosterPath, shorts) {
|
|
|
203
216
|
});
|
|
204
217
|
}
|
|
205
218
|
|
|
219
|
+
export async function removeClaudeJobState(jobsDir, short) {
|
|
220
|
+
if (!short) return;
|
|
221
|
+
await fs.rm(path.join(jobsDir, short), { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function shellSingleQuote(value) {
|
|
225
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function buildSwarmShardBridgeCommand({ displayName, sessionId }) {
|
|
229
|
+
const banner = `${displayName} native bridge active (${sessionId})`;
|
|
230
|
+
return `printf '%s\\n' ${shellSingleQuote(banner)}; while true; do sleep 3600; done`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function compactDisplayPart(value, fallback = "") {
|
|
234
|
+
const text = String(value ?? "")
|
|
235
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
236
|
+
.replace(/\s+/g, " ")
|
|
237
|
+
.trim();
|
|
238
|
+
return text || fallback;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function toPositiveInteger(value) {
|
|
242
|
+
const number = Number(value);
|
|
243
|
+
return Number.isInteger(number) && number > 0 ? number : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function formatShardOrdinal({ shardIndex, shardCount }) {
|
|
247
|
+
const index = toPositiveInteger(shardIndex);
|
|
248
|
+
const count = toPositiveInteger(shardCount);
|
|
249
|
+
if (!index || !count) return null;
|
|
250
|
+
const width = Math.max(String(count).length, 2);
|
|
251
|
+
return `${String(index).padStart(width, "0")}/${String(count).padStart(width, "0")}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildSwarmShardDisplayName({
|
|
255
|
+
sessionId,
|
|
256
|
+
cli,
|
|
257
|
+
swarmName,
|
|
258
|
+
shardIndex,
|
|
259
|
+
shardCount,
|
|
260
|
+
shardName,
|
|
261
|
+
}) {
|
|
262
|
+
const groupName = compactDisplayPart(swarmName || sessionId, "swarm");
|
|
263
|
+
const agentName = compactDisplayPart(cli, "agent");
|
|
264
|
+
const workerName = compactDisplayPart(shardName, "shard");
|
|
265
|
+
return [
|
|
266
|
+
"Triflux swarm",
|
|
267
|
+
groupName,
|
|
268
|
+
formatShardOrdinal({ shardIndex, shardCount }),
|
|
269
|
+
agentName,
|
|
270
|
+
workerName,
|
|
271
|
+
]
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.join(" ");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isLocalHost(host) {
|
|
277
|
+
const value = String(host || "local").toLowerCase();
|
|
278
|
+
return value === "local" || value === "localhost" || value === "127.0.0.1";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function deriveSwarmShort({ sessionId, shardName, host }) {
|
|
282
|
+
return crypto
|
|
283
|
+
.createHash("sha256")
|
|
284
|
+
.update(`${sessionId}:${shardName}:${host || "local"}`)
|
|
285
|
+
.digest("hex")
|
|
286
|
+
.slice(0, 8);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function registerSwarmShard({
|
|
290
|
+
sessionId,
|
|
291
|
+
cli = "codex",
|
|
292
|
+
role = "worker",
|
|
293
|
+
swarmName,
|
|
294
|
+
shardIndex,
|
|
295
|
+
shardCount,
|
|
296
|
+
shardName,
|
|
297
|
+
cwd = process.cwd(),
|
|
298
|
+
host = "local",
|
|
299
|
+
configDir = resolveClaudeConfigDir(),
|
|
300
|
+
tmpRoot = "/tmp",
|
|
301
|
+
_deps = {},
|
|
302
|
+
} = {}) {
|
|
303
|
+
if (!sessionId) throw new Error("sessionId is required");
|
|
304
|
+
if (!shardName) throw new Error("shardName is required");
|
|
305
|
+
|
|
306
|
+
const displayName = buildSwarmShardDisplayName({
|
|
307
|
+
sessionId,
|
|
308
|
+
cli,
|
|
309
|
+
swarmName,
|
|
310
|
+
shardIndex,
|
|
311
|
+
shardCount,
|
|
312
|
+
shardName,
|
|
313
|
+
});
|
|
314
|
+
const warn = _deps.warn || ((message) => console.warn(message));
|
|
315
|
+
if (!isLocalHost(host)) {
|
|
316
|
+
warn(
|
|
317
|
+
`[native-bridge] remote shard ${shardName}@${host}: skipping local registration; remote launcher must register on that host`,
|
|
318
|
+
);
|
|
319
|
+
return {
|
|
320
|
+
ok: true,
|
|
321
|
+
skipped: true,
|
|
322
|
+
host,
|
|
323
|
+
sessionId,
|
|
324
|
+
shardName,
|
|
325
|
+
displayName,
|
|
326
|
+
close() {},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const derivePaths = _deps.deriveClaudeDaemonPaths || deriveClaudeDaemonPaths;
|
|
331
|
+
const buildPayload =
|
|
332
|
+
_deps.buildDaemonExecDispatchPayload || buildDaemonExecDispatchPayload;
|
|
333
|
+
const sendControl =
|
|
334
|
+
_deps.sendClaudeControlRequest || sendClaudeControlRequest;
|
|
335
|
+
const waitForPid = _deps.waitForDaemonJobPid || waitForDaemonJobPid;
|
|
336
|
+
const readProcStart = _deps.getProcStart || getProcStart;
|
|
337
|
+
const buildProjection =
|
|
338
|
+
_deps.buildClaudeSessionProjection || buildClaudeSessionProjection;
|
|
339
|
+
const writeProjection =
|
|
340
|
+
_deps.writeClaudeSessionProjection || writeClaudeSessionProjection;
|
|
341
|
+
const removeProjection =
|
|
342
|
+
_deps.removeClaudeSessionProjection || removeClaudeSessionProjection;
|
|
343
|
+
const removeJobStateImpl = _deps.removeClaudeJobState || removeClaudeJobState;
|
|
344
|
+
const killJob = _deps.killDaemonJob || killDaemonJob;
|
|
345
|
+
const accessControlSock = _deps.accessControlSock || fs.access;
|
|
346
|
+
|
|
347
|
+
const paths = derivePaths({ configDir, tmpRoot });
|
|
348
|
+
const sessionsDir =
|
|
349
|
+
paths.sessionsDir || path.join(paths.configDir || configDir, "sessions");
|
|
350
|
+
const jobsDir =
|
|
351
|
+
paths.jobsDir || path.join(paths.configDir || configDir, "jobs");
|
|
352
|
+
const short = deriveSwarmShort({ sessionId, shardName, host });
|
|
353
|
+
const command = buildSwarmShardBridgeCommand({ displayName, sessionId });
|
|
354
|
+
const payload = buildPayload({
|
|
355
|
+
short,
|
|
356
|
+
cwd,
|
|
357
|
+
command,
|
|
358
|
+
name: displayName,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await accessControlSock(paths.controlSock);
|
|
362
|
+
const dispatch = await sendControl(
|
|
363
|
+
paths.controlSock,
|
|
364
|
+
{
|
|
365
|
+
proto: 1,
|
|
366
|
+
op: "dispatch",
|
|
367
|
+
d: payload,
|
|
368
|
+
timeoutMs: 1000,
|
|
369
|
+
},
|
|
370
|
+
{ timeoutMs: 1000 },
|
|
371
|
+
);
|
|
372
|
+
if (dispatch?.ok !== true) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Claude daemon dispatch failed for swarm shard ${shardName}`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const job = await waitForPid(paths.controlSock, short, { timeoutMs: 1000 });
|
|
379
|
+
const pid = job.pid;
|
|
380
|
+
const projection = buildProjection({
|
|
381
|
+
pid,
|
|
382
|
+
procStart: readProcStart(pid),
|
|
383
|
+
sessionId: payload.sessionId,
|
|
384
|
+
short,
|
|
385
|
+
cwd,
|
|
386
|
+
name: displayName,
|
|
387
|
+
agent: cli,
|
|
388
|
+
startedAt: job.startedAt || Date.now(),
|
|
389
|
+
updatedAt: Date.now(),
|
|
390
|
+
});
|
|
391
|
+
const sessionProjectionPath = await writeProjection(sessionsDir, projection);
|
|
392
|
+
let closed = false;
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
ok: true,
|
|
396
|
+
skipped: false,
|
|
397
|
+
host: "local",
|
|
398
|
+
sessionId: payload.sessionId,
|
|
399
|
+
swarmSessionId: sessionId,
|
|
400
|
+
shardName,
|
|
401
|
+
cli,
|
|
402
|
+
role,
|
|
403
|
+
short,
|
|
404
|
+
displayName,
|
|
405
|
+
controlSock: paths.controlSock,
|
|
406
|
+
sessionProjectionPath,
|
|
407
|
+
async close() {
|
|
408
|
+
if (closed) return;
|
|
409
|
+
closed = true;
|
|
410
|
+
await removeProjection(sessionProjectionPath).catch(() => {});
|
|
411
|
+
await killJob(paths.controlSock, short).catch(() => {});
|
|
412
|
+
await removeJobStateImpl(jobsDir, short).catch(() => {});
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
206
417
|
export function buildNativeWorkerRosterEntry({
|
|
207
418
|
short,
|
|
208
419
|
pid = process.pid,
|
|
@@ -96,6 +96,7 @@ export async function teamStart(args = []) {
|
|
|
96
96
|
cwd,
|
|
97
97
|
nativeBridge,
|
|
98
98
|
nativeBridgeMode,
|
|
99
|
+
nativeBridgeUiOptOut,
|
|
99
100
|
} = parseTeamArgs(args);
|
|
100
101
|
// --assign 사용 시 task를 자동 생성
|
|
101
102
|
const task =
|
|
@@ -133,6 +134,8 @@ export async function teamStart(args = []) {
|
|
|
133
134
|
const { mode: effectiveMode, warnings: modeWarnings } =
|
|
134
135
|
resolveEffectiveMode(teammateMode);
|
|
135
136
|
for (const message of modeWarnings) warn(message);
|
|
137
|
+
const effectiveNativeBridge =
|
|
138
|
+
effectiveMode === "headless" && !nativeBridgeUiOptOut ? true : nativeBridge;
|
|
136
139
|
|
|
137
140
|
console.log(` 세션: ${WHITE}${sessionId}${RESET}`);
|
|
138
141
|
console.log(` 모드: ${effectiveMode}`);
|
|
@@ -180,7 +183,7 @@ export async function teamStart(args = []) {
|
|
|
180
183
|
mcpProfile,
|
|
181
184
|
model,
|
|
182
185
|
cwd,
|
|
183
|
-
nativeBridge,
|
|
186
|
+
nativeBridge: effectiveNativeBridge,
|
|
184
187
|
nativeBridgeMode,
|
|
185
188
|
})
|
|
186
189
|
: effectiveMode === "wt"
|
|
@@ -77,7 +77,10 @@ export function parseTeamArgs(args = []) {
|
|
|
77
77
|
let model = "";
|
|
78
78
|
let cwd = "";
|
|
79
79
|
let nativeBridge = false;
|
|
80
|
-
let nativeBridgeMode = "
|
|
80
|
+
let nativeBridgeMode = "agents";
|
|
81
|
+
let nativeBridgeExplicit = false;
|
|
82
|
+
let nativeBridgeUiRequested = false;
|
|
83
|
+
let nativeBridgeUiOptOut = false;
|
|
81
84
|
|
|
82
85
|
for (let index = 0; index < args.length; index += 1) {
|
|
83
86
|
const current = args[index];
|
|
@@ -127,9 +130,25 @@ export function parseTeamArgs(args = []) {
|
|
|
127
130
|
model = args[++index].trim();
|
|
128
131
|
} else if (current === "--native-bridge" || current === "-nb") {
|
|
129
132
|
nativeBridge = true;
|
|
133
|
+
nativeBridgeExplicit = true;
|
|
130
134
|
} else if (current === "--native-bridge-ui") {
|
|
135
|
+
if (nativeBridgeUiOptOut) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"cannot combine --native-bridge-ui and --no-native-bridge-ui",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
131
140
|
nativeBridge = true;
|
|
132
141
|
nativeBridgeMode = "agents";
|
|
142
|
+
nativeBridgeExplicit = true;
|
|
143
|
+
nativeBridgeUiRequested = true;
|
|
144
|
+
} else if (current === "--no-native-bridge-ui") {
|
|
145
|
+
if (nativeBridgeUiRequested) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"cannot combine --native-bridge-ui and --no-native-bridge-ui",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
nativeBridge = false;
|
|
151
|
+
nativeBridgeUiOptOut = true;
|
|
133
152
|
} else if (current === "--native-bridge-mode") {
|
|
134
153
|
const mode = args[++index];
|
|
135
154
|
if (!NATIVE_BRIDGE_MODES.has(mode)) {
|
|
@@ -139,6 +158,7 @@ export function parseTeamArgs(args = []) {
|
|
|
139
158
|
}
|
|
140
159
|
nativeBridge = true;
|
|
141
160
|
nativeBridgeMode = mode;
|
|
161
|
+
nativeBridgeExplicit = true;
|
|
142
162
|
} else if (current === "--cwd" && args[index + 1]) {
|
|
143
163
|
let p = args[++index].trim();
|
|
144
164
|
// MSYS/Git Bash 드라이브 문자 변환: /c/... → C:/...
|
|
@@ -153,11 +173,20 @@ export function parseTeamArgs(args = []) {
|
|
|
153
173
|
}
|
|
154
174
|
}
|
|
155
175
|
|
|
176
|
+
const normalizedTeammateMode = normalizeTeammateMode(teammateMode);
|
|
177
|
+
if (
|
|
178
|
+
!nativeBridgeExplicit &&
|
|
179
|
+
!nativeBridgeUiOptOut &&
|
|
180
|
+
normalizedTeammateMode === "headless"
|
|
181
|
+
) {
|
|
182
|
+
nativeBridge = true;
|
|
183
|
+
}
|
|
184
|
+
|
|
156
185
|
return {
|
|
157
186
|
agents,
|
|
158
187
|
lead,
|
|
159
188
|
layout: normalizeLayout(layout),
|
|
160
|
-
teammateMode:
|
|
189
|
+
teammateMode: normalizedTeammateMode,
|
|
161
190
|
task: taskParts.join(" ").trim(),
|
|
162
191
|
assigns,
|
|
163
192
|
autoAttach,
|
|
@@ -173,5 +202,6 @@ export function parseTeamArgs(args = []) {
|
|
|
173
202
|
cwd,
|
|
174
203
|
nativeBridge,
|
|
175
204
|
nativeBridgeMode,
|
|
205
|
+
nativeBridgeUiOptOut,
|
|
176
206
|
};
|
|
177
207
|
}
|